diff --git a/.azure/pipelines/build.yaml b/.azure/pipelines/build.yaml new file mode 100644 index 000000000000..7ce3f0c1efa9 --- /dev/null +++ b/.azure/pipelines/build.yaml @@ -0,0 +1,94 @@ +trigger: + branches: + include: + - main + paths: + include: + - dotnet + +schedules: +- cron: "0 0 * * *" + displayName: 'Daily midnight build (including CodeQL)' + branches: + include: + - main + always: true + +parameters: + - name: build_configuration + displayName: Build configuration + type: string + default: Release + values: + - Release + - Debug + - name: include_suffix + displayName: Append version suffix + type: boolean + default: true + - name: version_suffix + displayName: Version suffix + type: string + default: dev.$(Build.BuildNumber) + - name: codesign + displayName: Enable code signing + type: boolean + default: false + - name: skip_test + displayName: Skip tests + type: boolean + default: false + - name: publish_nuget + displayName: Publish to nuget.org + type: boolean + default: false + - name: publish_nightly + displayName: Publish to autogen-nightly + type: boolean + default: true + - name: publish_artifacts + displayName: Publish artifacts + type: boolean + default: false + - name: runCodeQL3000 + default: false + displayName: Run CodeQL3000 tasks + type: boolean + +variables: +- template: templates/vars.yaml + +resources: + repositories: + - repository: 1ESPipelineTemplates + type: git + name: 1ESPipelineTemplates/1ESPipelineTemplates + ref: refs/tags/release + +extends: + ${{ if eq(variables['System.TeamProject'], 'GitHub - PR Builds') }}: + template: v1/1ES.Unofficial.PipelineTemplate.yml@1ESPipelineTemplates + ${{ else }}: + template: v1/1ES.Official.PipelineTemplate.yml@1ESPipelineTemplates + parameters: + settings: + skipBuildTagsForGitHubPullRequests: true + pool: + name: $(pool_name) + image: $(pool_image) + os: windows + stages: + - stage: build_test + displayName: Build and Tests + jobs: + - template: /.azure/pipelines/templates/build.yaml@self + parameters: + build_configuration: ${{ parameters.build_configuration }} + include_suffix: ${{ parameters.include_suffix }} + version_suffix: ${{ parameters.version_suffix }} + codesign: ${{ parameters.codesign }} + skip_test: ${{ parameters.skip_test }} + publish_nightly: ${{ parameters.publish_nightly }} + publish_nuget: ${{ parameters.publish_nuget }} + runCodeQL3000: ${{ parameters.runCodeQL3000 }} + publish_artifacts: ${{ parameters.publish_artifacts }} \ No newline at end of file diff --git a/.azure/pipelines/templates/build.yaml b/.azure/pipelines/templates/build.yaml new file mode 100644 index 000000000000..0b7dbe990c38 --- /dev/null +++ b/.azure/pipelines/templates/build.yaml @@ -0,0 +1,228 @@ +parameters: + - name: build_configuration + displayName: Build configuration + type: string + default: Release + values: + - Release + - Debug + - name: include_suffix + displayName: Append version suffix + type: boolean + default: true + - name: version_suffix + displayName: Version suffix + type: string + default: ci.$(Build.BuildNumber) + - name: codesign + displayName: Enable code signing + type: boolean + default: false + - name: skip_test + displayName: Skip tests + type: boolean + default: false + - name: publish_nightly + displayName: Publish to autogen-nightly + type: boolean + default: false + - name: publish_nuget + displayName: Publish to nuget.org + type: boolean + default: false + - name: publish_artifacts + displayName: Publish artifacts + type: boolean + default: false + - name: runCodeQL3000 + default: false + displayName: Run CodeQL3000 tasks + type: boolean + +jobs: + +# Build, sign dlls, build nuget pkgs, then sign them +- job: Build + displayName: Build and create NuGet packages + variables: + publishVstsFeed: 'AGPublic/AutoGen-Nightly' + ${{ if eq(parameters.codesign, true) }}: + esrp_signing: true + ${{ else }}: + esrp_signing: false + ${{ if ne(variables['System.TeamProject'], 'GitHub - PR Builds') }}: + templateContext: + outputs: + # Publish artifacts if enabled + - ${{ if eq(parameters.publish_artifacts, true) }}: # TODO add eq(parameters.codesign, true) + - output: pipelineArtifact + targetPath: '$(build.sourcesdirectory)/dotnet/artifacts' + artifactName: artifacts folder + # Publish packages to nightly + - ${{ if eq(parameters.publish_nightly, true) }}: # TODO add eq(parameters.codesign, true) + - output: nuget + useDotNetTask: false + packageParentPath: $(Pipeline.Workspace) + packagesToPush: $(build.sourcesdirectory)/dotnet/artifacts/**/*.nupkg;$(build.sourcesdirectory)/dotnet/artifacts/**/*.snupkg + nuGetFeedType: internal + publishVstsFeed: $(publishVstsFeed) + allowPackageConflicts: true + - ${{ if and(eq(parameters.codesign, true), eq(parameters.publish_nuget, true)) }}: + - output: nuget + condition: succeeded() + useDotNetTask: false + packageParentPath: $(Pipeline.Workspace) + packagesToPush: $(build.sourcesdirectory)/dotnet/artifacts/**/*.nupkg;$(build.sourcesdirectory)/dotnet/artifacts/**/*.snupkg + nuGetFeedType: external + publishFeedCredentials: dotnet-orleans-nuget + publishPackageMetadata: true + allowPackageConflicts: true + steps: + - checkout: self + - task: UseDotNet@2 + displayName: 'Use .NET Core sdk' + inputs: + useGlobalJson: true + workingDirectory: $(Build.SourcesDirectory)/dotnet + - task: Bash@3 + displayName: Install .NET Aspire workload + inputs: + targetType: 'inline' + script: | + dotnet nuget locals all --clear + dotnet workload install aspire + - ${{ if eq(variables.runCodeQL3000, 'true') }}: + - task: CodeQL3000Init@0 + displayName: CodeQL Initialize + # This task only tags a build if it actually does CodeQL3000 work. + # Those tasks no-op while the analysis is considered up to date i.e. for runs w/in a few days of each other. + - script: "echo ##vso[build.addbuildtag]CodeQL3000" + displayName: 'Set CI CodeQL3000 tag' + condition: ne(variables.CODEQL_DIST,'') + - task: DotNetCoreCLI@2 + displayName: Build + inputs: + command: build + arguments: '$(build_flags) /bl:${{parameters.build_configuration}}-Build.binlog /p:Configuration=${{parameters.build_configuration}} $(solution)' + workingDirectory: $(Build.SourcesDirectory)/dotnet + env: + ${{ if and(eq(parameters.include_suffix, true), eq(parameters.publish_nuget, false)) }}: + VersionSuffix: ${{parameters.version_suffix}} + OfficialBuild: $(official_build) + + - ${{ if eq(variables.runCodeQL3000, 'true') }}: + - task: CodeQL3000Finalize@0 + displayName: CodeQL Finalize + # DLL code signing + - ${{ if eq(variables.esrp_signing, true) }}: + - task: UseDotNet@2 + displayName: 'Codesign: Use .NET Core' + inputs: + packageType: runtime + version: $(codesign_runtime) + - task: CopyFiles@2 + displayName: 'Codesign: Copy Files for signing' + inputs: + SourceFolder: '$(build.sourcesdirectory)' + Contents: | + src/**/bin/${{parameters.build_configuration}}/**/AutoGen*.dll + src/**/bin/${{parameters.build_configuration}}/**/Microsoft.AutoGen.*.dll + TargetFolder: '$(build.artifactstagingdirectory)\codesign' + CleanTargetFolder: true + - task: SFP.build-tasks.custom-build-task-1.EsrpCodeSigning@1 + displayName: 'Codesign: ESRP CodeSigning' + inputs: + ConnectedServiceName: 'CodeSign Service (NuGet)' + FolderPath: '$(build.artifactstagingdirectory)\codesign' + Pattern: '*' + signConfigType: inlineSignParams + inlineOperation: | + [ + { + "keyCode": "CP-230012", + "operationSetCode": "SigntoolSign", + "parameters": [ + { + "parameterName": "OpusName", + "parameterValue": "Microsoft" + }, + { + "parameterName": "OpusInfo", + "parameterValue": "http://www.microsoft.com" + }, + { + "parameterName": "FileDigest", + "parameterValue": "/fd \"SHA256\"" + }, + { + "parameterName": "PageHash", + "parameterValue": "/NPH" + }, + { + "parameterName": "TimeStamp", + "parameterValue": "/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256" + } + ], + "toolName": "sign", + "toolVersion": "1.0" + }, + { + "keyCode": "CP-230012", + "operationSetCode": "SigntoolVerify", + "parameters": [ ], + "toolName": "sign", + "toolVersion": "1.0" + } + ] + SessionTimeout: 180 + VerboseLogin: true + - task: CopyFiles@2 + displayName: 'Codesign: Copy Signed Files Back' + inputs: + SourceFolder: '$(build.artifactstagingdirectory)\codesign' + Contents: '**\*' + TargetFolder: '$(build.sourcesdirectory)' + OverWrite: true + # End DLL code signing + - task: CmdLine@2 + displayName: Pack + inputs: + script: 'dotnet pack --no-build --no-restore $(build_flags) /bl:${{parameters.build_configuration}}-Pack.binlog /p:Configuration=${{parameters.build_configuration}} $(solution)' + workingDirectory: $(Build.SourcesDirectory)/dotnet + env: + ${{ if and(eq(parameters.include_suffix, true), eq(parameters.publish_nuget, false)) }}: + VersionSuffix: ${{parameters.version_suffix}} + OfficialBuild: $(official_build) + # NuGet code signing + - ${{ if eq(variables.esrp_signing, true) }}: + - task: UseDotNet@2 + displayName: 'Codesign: Use .NET Core' + inputs: + packageType: runtime + version: $(codesign_runtime) + - task: SFP.build-tasks.custom-build-task-1.EsrpCodeSigning@1 + displayName: 'Codesign: ESRP CodeSigning (nuget)' + inputs: + ConnectedServiceName: 'CodeSign Service (NuGet)' + FolderPath: '$(build.sourcesdirectory)/Artifacts/${{parameters.build_configuration}}' + Pattern: '*.nupkg' + signConfigType: inlineSignParams + inlineOperation: | + [ + { + "keyCode": "CP-401405", + "operationSetCode": "NuGetSign", + "parameters": [], + "toolName": "sign", + "toolVersion": "1.0" + }, + { + "keyCode": "CP-401405", + "operationSetCode": "NuGetVerify", + "parameters": [ ], + "toolName": "sign", + "toolVersion": "1.0" + } + ] + SessionTimeout: 180 + VerboseLogin: true diff --git a/.azure/pipelines/templates/vars.yaml b/.azure/pipelines/templates/vars.yaml new file mode 100644 index 000000000000..0b735a02500f --- /dev/null +++ b/.azure/pipelines/templates/vars.yaml @@ -0,0 +1,34 @@ +# It seems that variables must be defined in their own file when using templates + +variables: + build_flags: ' /m /v:m' + solution: 'AutoGen.sln' + codesign_runtime: '2.1.x' + GDN_SUPPRESS_FORKED_BUILD_WARNING: true # Avoid warning "Guardian is not supported for builds from forked GitHub repositories" + MicroBuildOutputFolderOverride: '$(Agent.TempDirectory)' + # Auto-injection is not necessary because the tasks are explicitly included where they're enabled. + Codeql.SkipTaskAutoInjection: true + ${{ if eq(variables['System.TeamProject'], 'GitHub - PR Builds') }}: + pool_name: '1es-agpublish-pool' + pool_image: 'agpublish-agent-image' + official_build: false + ${{ else }}: + ${{ if eq(variables['System.TeamProject'], 'internal') }}: + pool_name: '1es-agpublish-pool' + pool_image: 'agpublish-agent-image' + ${{ else }}: + pool_name: '1es-agpublish-pool' + pool_image: 'agpublish-agent-image' + official_build: true + # Do not let CodeQL3000 Extension gate scan frequency. + Codeql.Cadence: 0 + # Enable CodeQL3000 unconditionally so it may be run on any branch. + Codeql.Enabled: true + # Ignore test and infrastructure code. + Codeql.SourceRoot: src + # CodeQL3000 needs this plumbed along as a variable to enable TSA. Don't use TSA in manual builds. + Codeql.TSAEnabled: ${{ eq(variables['Build.Reason'], 'Schedule') }} + # Default expects tsaoptions.json under SourceRoot. + Codeql.TSAOptionsPath: '$(Build.SourcesDirectory)/.config/tsaoptions.json' + # Do not slow builds down w/ the CodeQL3000 tasks unless this is a nightly build or it's requested. + runCodeQL3000: ${{ or(eq(variables['Build.Reason'], 'Schedule'), and(eq(variables['Build.Reason'], 'Manual'), eq(parameters.runCodeQL3000, 'true'))) }} \ No newline at end of file diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index b6d8b112c22f..31869b86ccc2 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -153,6 +153,27 @@ jobs: poe --directory ${{ matrix.package }} docs-check working-directory: ./python + docs-example-check: + runs-on: ubuntu-latest + strategy: + matrix: + package: ["./packages/autogen-core"] + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: uv sync --locked --all-extras + working-directory: ./python + - name: Run task + run: | + source ${{ github.workspace }}/python/.venv/bin/activate + poe --directory ${{ matrix.package }} docs-check-examples + working-directory: ./python + check-proto-changes-python: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0218a370535a..57ceb261fdec 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -39,6 +39,7 @@ jobs: { ref: "v0.4.0.dev4", dest-dir: "0.4.0.dev4" }, { ref: "v0.4.0.dev5", dest-dir: "0.4.0.dev5" }, { ref: "v0.4.0.dev6", dest-dir: "0.4.0.dev6" }, + { ref: "v0.4.0.dev7", dest-dir: "0.4.0.dev7" }, ] steps: - name: Checkout diff --git a/.gitignore b/.gitignore index fa3abc2ae986..bd419b14d7c7 100644 --- a/.gitignore +++ b/.gitignore @@ -193,3 +193,6 @@ notebook/result.png samples/apps/autogen-studio/autogenstudio/models/test/ notebook/coding + +# dotnet artifacts +artifacts \ No newline at end of file diff --git a/README.md b/README.md index 71509427170e..253a6b0ea073 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ # AutoGen > [!IMPORTANT] -> +> - (11/14/24) ⚠️ In response to a number of asks to clarify and distinguish between official AutoGen and its forks that created confusion, we issued a [clarification statement](https://github.com/microsoft/autogen/discussions/4217). > - (10/13/24) Interested in the standard AutoGen as a prior user? Find it at the actively-maintained *AutoGen* [0.2 branch](https://github.com/microsoft/autogen/tree/0.2) and `autogen-agentchat~=0.2` PyPi package. > - (10/02/24) [AutoGen 0.4](https://microsoft.github.io/autogen/dev) is a from-the-ground-up rewrite of AutoGen. Learn more about the history, goals and future at [this blog post](https://microsoft.github.io/autogen/blog). We’re excited to work with the community to gather feedback, refine, and improve the project before we officially release 0.4. This is a big change, so AutoGen 0.2 is still available, maintained, and developed in the [0.2 branch](https://github.com/microsoft/autogen/tree/0.2). @@ -101,7 +101,7 @@ We look forward to your contributions! First install the packages: ```bash -pip install 'autogen-agentchat==0.4.0.dev6' 'autogen-ext[openai]==0.4.0.dev6' +pip install 'autogen-agentchat==0.4.0.dev7' 'autogen-ext[openai]==0.4.0.dev7' ``` The following code uses OpenAI's GPT-4o model and you need to provide your diff --git a/docs/design/01 - Programming Model.md b/docs/design/01 - Programming Model.md index 732a46c6aebe..6bfa9f9766ad 100644 --- a/docs/design/01 - Programming Model.md +++ b/docs/design/01 - Programming Model.md @@ -6,23 +6,27 @@ The programming model is basically publish-subscribe. Agents subscribe to events ## Events Delivered as CloudEvents -Each event in the system is defined using the [CloudEvents Specification](https://cloudevents.io/). This allows for a common event format that can be used across different systems and languages. In CloudEvents, each event has a Context Attributes that must unique *id* (eg a UUID) a *source* (a unique urn or path), a *type* (the namespace of the event - prefixed with a reverse-DNS name. The prefixed domain dictates the organization which defines the semantics of this event type: e.g *com.github.pull_request.opened* or -*com.example.object.deleted.v2*), and optionally fields describing the data schema/content-type or extensions. +Each event in the system is defined using the [CloudEvents Specification](https://cloudevents.io/). This allows for a common event format that can be used across different systems and languages. In CloudEvents, each event has "Context Attributes" that must include: + +1. *id* - A unique id (eg. a UUID). +2. *source* - A URI or URN indicating the event's origin. +3. *type* - The namespace of the event - prefixed with a reverse-DNS name. + - The prefixed domain dictates the organization which defines the semantics of this event type: e.g `com.github.pull_request.opened` or `com.example.object.deleted.v2`), and optionally fields describing the data schema/content-type or extensions. ## Event Handlers -Each agent has a set of event handlers, that are bound to a specific match against a CloudEvents *type*. Event Handlers could match against an exact type or match for a pattern of events of a particular level in the type heirarchy (eg: *com.Microsoft.AutoGen.Agents.System.\** for all Events in the *System* namespace) Each event handler is a function that can change state, call models, access memory, call external tools, emit other events, and flow data to/from other systems. Each event handler can be a simple function or a more complex function that uses a state machine or other control logic. +Each agent has a set of event handlers, that are bound to a specific match against a CloudEvents *type*. Event Handlers could match against an exact type or match for a pattern of events of a particular level in the type heirarchy (eg: `com.Microsoft.AutoGen.Agents.System.*` for all Events in the `System` namespace) Each event handler is a function that can change state, call models, access memory, call external tools, emit other events, and flow data to/from other systems. Each event handler can be a simple function or a more complex function that uses a state machine or other control logic. ## Orchestrating Agents -If is possible to build a functional and scalable agent system that only reacts to external events. In many cases, however, you will want to orchestrate the agents to achieve a specific goal or follow a pre-determined workflow. In this case, you will need to build an orchestrator agent that manages the flow of events between agents. +It is possible to build a functional and scalable agent system that only reacts to external events. In many cases, however, you will want to orchestrate the agents to achieve a specific goal or follow a pre-determined workflow. In this case, you will need to build an orchestrator agent that manages the flow of events between agents. ## Built-in Event Types The AutoGen system comes with a set of built-in event types that are used to manage the system. These include: -* System Events - Events that are used to manage the system itself. These include events for starting and stopping the Agents, sending messages to all agents, and other system-level events. -* ? insert other types here ? +- *System Events* - Events that are used to manage the system itself. These include events for starting and stopping the Agents, sending messages to all agents, and other system-level events. +- *Insert other types here* ## Agent Contracts diff --git a/docs/design/02 - Topics.md b/docs/design/02 - Topics.md index aca577e748fa..7d7149c37c91 100644 --- a/docs/design/02 - Topics.md +++ b/docs/design/02 - Topics.md @@ -17,16 +17,16 @@ This document does not specify RPC/direct messaging A topic is identified by two components (called a `TopicId`): - [`type`](https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md#type) - represents the type of event that occurs, this is static and defined in code - - SHOULD use reverse domain name notation to avoid naming conflicts. For example: `com.example.my-topic`. + - SHOULD use reverse domain name notation to avoid naming conflicts. For example: `com.example.my-topic`. - [`source`](https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md#source-1) - represents where the event originated from, this is dynamic and based on the message itself - - SHOULD be a URI + - SHOULD be a URI Agent instances are identified by two components (called an `AgentId`): - `type` - represents the type of agent, this is static and defined in code - - MUST be a valid identifier as defined [here](https://docs.python.org/3/reference/lexical_analysis.html#identifiers) except that only the ASCII range is allowed + - MUST be a valid identifier as defined [here](https://docs.python.org/3/reference/lexical_analysis.html#identifiers) except that only the ASCII range is allowed - `key` - represents the instance of the agent type for the key - - SHOULD be a URI + - SHOULD be a URI For example: `GraphicDesigner:1234` diff --git a/docs/design/03 - Agent Worker Protocol.md b/docs/design/03 - Agent Worker Protocol.md index 49d9e867191b..81a9b9b7e97a 100644 --- a/docs/design/03 - Agent Worker Protocol.md +++ b/docs/design/03 - Agent Worker Protocol.md @@ -22,7 +22,7 @@ Agents are never explicitly created or destroyed. When a request is received for ## Worker protocol flow -The worker protocol has three phases, following the lifetime of the worker: initiation, operation, and termination. +The worker protocol has three phases, following the lifetime of the worker: initialization, operation, and termination. ### Initialization diff --git a/docs/design/04 - Agent and Topic ID Specs.md b/docs/design/04 - Agent and Topic ID Specs.md index 22a8a08894fb..ee872ab2ac1e 100644 --- a/docs/design/04 - Agent and Topic ID Specs.md +++ b/docs/design/04 - Agent and Topic ID Specs.md @@ -8,23 +8,23 @@ This document describes the structure, constraints, and behavior of Agent IDs an #### type -* Type: `string` -* Description: The agent type is not an agent class. It associates an agent with a specific factory function, which produces instances of agents of the same agent `type`. For example, different factory functions can produce the same agent class but with different constructor perameters. -* Constraints: UTF8 and only contain alphanumeric letters (a-z) and (0-9), or underscores (_). A valid identifier cannot start with a number, or contain any spaces. -* Examples: - * `code_reviewer` - * `WebSurfer` - * `UserProxy` +- Type: `string` +- Description: The agent type is not an agent class. It associates an agent with a specific factory function, which produces instances of agents of the same agent `type`. For example, different factory functions can produce the same agent class but with different constructor perameters. +- Constraints: UTF8 and only contain alphanumeric letters (a-z) and (0-9), or underscores (\_). A valid identifier cannot start with a number, or contain any spaces. +- Examples: + - `code_reviewer` + - `WebSurfer` + - `UserProxy` #### key -* Type: `string` -* Description: The agent key is an instance identifier for the given agent `type` -* Constraints: UTF8 and only contain characters between (inclusive) ascii 32 (space) and 126 (~). -* Examples: - * `default` - * A memory address - * a UUID string +- Type: `string` +- Description: The agent key is an instance identifier for the given agent `type` +- Constraints: UTF8 and only contain characters between (inclusive) ascii 32 (space) and 126 (~). +- Examples: + - `default` + - A memory address + - a UUID string ## Topic ID @@ -32,16 +32,16 @@ This document describes the structure, constraints, and behavior of Agent IDs an #### type -* Type: `string` -* Description: topic type is usually defined by application code to mark the type of messages the topic is for. -* Constraints: UTF8 and only contain alphanumeric letters (a-z) and (0-9), or underscores (_). A valid identifier cannot start with a number, or contain any spaces. -* Examples: - * `GitHub_Issues` +- Type: `string` +- Description: Topic type is usually defined by application code to mark the type of messages the topic is for. +- Constraints: UTF8 and only contain alphanumeric letters (a-z) and (0-9), or underscores (\_). A valid identifier cannot start with a number, or contain any spaces. +- Examples: + - `GitHub_Issues` #### source -* Type: `string` -* Description: Topic source is the unique identifier for a topic within a topic type. It is typically defined by application data. -* Constraints: UTF8 and only contain characters between (inclusive) ascii 32 (space) and 126 (~). -* Examples: - * `github.com/{repo_name}/issues/{issue_number}` \ No newline at end of file +- Type: `string` +- Description: Topic source is the unique identifier for a topic within a topic type. It is typically defined by application data. +- Constraints: UTF8 and only contain characters between (inclusive) ascii 32 (space) and 126 (~). +- Examples: + - `github.com/{repo_name}/issues/{issue_number}` diff --git a/docs/switcher.json b/docs/switcher.json index a4e6748ebea7..5cfbe4c05516 100644 --- a/docs/switcher.json +++ b/docs/switcher.json @@ -41,7 +41,12 @@ { "name": "0.4.0.dev6", "version": "0.4.0.dev6", - "url": "/autogen/0.4.0.dev6/", + "url": "/autogen/0.4.0.dev6/" + }, + { + "name": "0.4.0.dev7", + "version": "0.4.0.dev7", + "url": "/autogen/0.4.0.dev7/", "preferred": true } ] diff --git a/dotnet/.gitignore b/dotnet/.gitignore index 2fc32d9ac7e4..62205af71a07 100644 --- a/dotnet/.gitignore +++ b/dotnet/.gitignore @@ -82,6 +82,7 @@ BenchmarkDotNet.Artifacts/ project.lock.json project.fragment.lock.json artifacts/ +appsettings.Development.json # Tye .tye/ diff --git a/dotnet/.tools/test-aot-compatibility.ps1 b/dotnet/.tools/test-aot-compatibility.ps1 index 071edcd956dc..d70139fd1fd2 100644 --- a/dotnet/.tools/test-aot-compatibility.ps1 +++ b/dotnet/.tools/test-aot-compatibility.ps1 @@ -15,7 +15,7 @@ foreach ($line in $($publishOutput -split "`r`n")) } } -pushd $rootDirectory/test/AutoGen.AotCompatibility.Tests/bin/Release/$targetNetFramework/linux-x64 +pushd $rootDirectory/artifacts/bin/AutoGen.AotCompatibility.Tests/release Write-Host "Executing test App..." ./AutoGen.AotCompatibility.Tests diff --git a/dotnet/AutoGen.sln b/dotnet/AutoGen.sln index accf92218ab2..5b26e27165b3 100644 --- a/dotnet/AutoGen.sln +++ b/dotnet/AutoGen.sln @@ -122,15 +122,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HelloAIAgents", "samples\He EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HelloAgent", "samples\Hello\HelloAgent\HelloAgent.csproj", "{8F7560CF-EEBB-4333-A69F-838CA40FD85D}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AIModelClientHostingExtensions", "src\Microsoft.AutoGen\Extensions\AIModelClientHostingExtensions\AIModelClientHostingExtensions.csproj", "{97550E87-48C6-4EBF-85E1-413ABAE9DBFD}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AutoGen.Extensions.MEAI", "src\Microsoft.AutoGen\Extensions\MEAI\Microsoft.AutoGen.Extensions.MEAI.csproj", "{97550E87-48C6-4EBF-85E1-413ABAE9DBFD}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sample", "sample", "{686480D7-8FEC-4ED3-9C5D-CEBE1057A7ED}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HelloAgentState", "samples\Hello\HelloAgentState\HelloAgentState.csproj", "{64EF61E7-00A6-4E5E-9808-62E10993A0E5}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AutoGen.ServiceDefaults", "src\Microsoft.AutoGen\Extensions\ServiceDefaults\Microsoft.AutoGen.ServiceDefaults.csproj", "{65059914-5527-4A00-9308-9FAF23D5E85A}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AutoGen.Extensions.Aspire", "src\Microsoft.AutoGen\Extensions\Aspire\Microsoft.AutoGen.Extensions.Aspire.csproj", "{65059914-5527-4A00-9308-9FAF23D5E85A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AutoGen.Agents.Tests", "test\Microsoft.AutoGen.Agents.Tests\Microsoft.AutoGen.Agents.Tests.csproj", "{394FDAF8-74F9-4977-94A5-3371737EB774}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AutoGen.Agents.Tests", "test\Microsoft.AutoGen.Agents.Tests\Microsoft.AutoGen.Agents.Tests.csproj", "{394FDAF8-74F9-4977-94A5-3371737EB774}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/dotnet/AutoGen.v3.ncrunchsolution b/dotnet/AutoGen.v3.ncrunchsolution new file mode 100644 index 000000000000..13107d39442c --- /dev/null +++ b/dotnet/AutoGen.v3.ncrunchsolution @@ -0,0 +1,8 @@ + + + True + True + True + True + + \ No newline at end of file diff --git a/dotnet/Directory.Build.props b/dotnet/Directory.Build.props index 1e84f78232ad..e548a4b7ea83 100644 --- a/dotnet/Directory.Build.props +++ b/dotnet/Directory.Build.props @@ -3,6 +3,7 @@ + true netstandard2.0;net8.0 net8.0 preview @@ -20,7 +21,6 @@ true true false - embedded true @@ -29,6 +29,11 @@ $(MSBuildThisFileDirectory) + + $(VersionPrefixForAutoGen0_2) + true + + $(NoWarn);CA1829 diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index c1b26f2f9b91..9bde32e6d012 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -4,26 +4,25 @@ 1.22.0 1.22.0-alpha 9.0.0-preview.9.24525.1 - direct - + - - - + + + - - + + - + - + - + @@ -34,90 +33,92 @@ - + - + - + - + - + - - - - - - - - - - - - - + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - - - - - - - - - + + + + + + + + + + + + - + - - - + + + - - + + - - - - - + + + + + - - - - + + + + + - - + + + - + - + \ No newline at end of file diff --git a/dotnet/PACKAGING.md b/dotnet/PACKAGING.md new file mode 100644 index 000000000000..af03850f7cea --- /dev/null +++ b/dotnet/PACKAGING.md @@ -0,0 +1,41 @@ +# Packaging AutoGen.NET + +This document describes the steps to pack the `AutoGen.NET` project. + +## Prerequisites + +- .NET SDK + +## Create Package + +1. **Restore and Build the Project** +```bash +dotnet restore +dotnet build --configuration Release --no-restore +``` + + +2. **Create the NuGet Package** +```bash +dotnet pack --configuration Release --no-build +``` + +This will generate both the `.nupkg` file and the `.snupkg` file in the `./artifacts/package/release` directory. + +For more details, refer to the [official .NET documentation](https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-pack). + +## Add new project to package list. +By default, when you add a new project to `AutoGen.sln`, it will not be included in the package list. To include the new project in the package, you need to add the following line to the new project's `.csproj` file + +e.g. + +```xml + +``` + +The `nuget-packages.props` enables `IsPackable` to `true` for the project, it also sets nenecessary metadata for the package. + +For more details, refer to the [NuGet folder](./nuget/README.md). + +## Package versioning +The version of the package is defined by `VersionPrefix` and `VersionPrefixForAutoGen0_2` in [MetaInfo.props](./eng/MetaInfo.props). If the name of your project starts with `AutoGen.`, the version will be set to `VersionPrefixForAutoGen0_2`, otherwise it will be set to `VersionPrefix`. diff --git a/dotnet/README.md b/dotnet/README.md index a5705114d72a..c34679389fc0 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -1,12 +1,12 @@ # AutoGen for .NET Thre are two sets of packages here: -Autogen.\* the older packages derived from Autogen 0.2 for .NET - these will gradually be deprecated and ported into the new packages +AutoGen.\* the older packages derived from AutoGen 0.2 for .NET - these will gradually be deprecated and ported into the new packages Microsoft.AutoGen.* the new packages for .NET that use the event-driven model - These APIs are not yet stable and are subject to change. To get started with the new packages, please see the [samples](./samples/) and in particular the [Hello](./samples/Hello) sample. -The remaining content is for the older Autogen.* packages. +You can install both new and old packages from the following feeds: [![dotnet-ci](https://github.com/microsoft/autogen/actions/workflows/dotnet-build.yml/badge.svg)](https://github.com/microsoft/autogen/actions/workflows/dotnet-build.yml) [![NuGet version](https://badge.fury.io/nu/AutoGen.Core.svg)](https://badge.fury.io/nu/AutoGen.Core) @@ -14,9 +14,7 @@ The remaining content is for the older Autogen.* packages. > [!NOTE] > Nightly build is available at: > -> - ![Static Badge](https://img.shields.io/badge/public-blue?style=flat) ![Static Badge](https://img.shields.io/badge/nightly-yellow?style=flat) ![Static Badge](https://img.shields.io/badge/github-grey?style=flat): -> - ![Static Badge](https://img.shields.io/badge/public-blue?style=flat) ![Static Badge](https://img.shields.io/badge/nightly-yellow?style=flat) ![Static Badge](https://img.shields.io/badge/myget-grey?style=flat): -> - ![Static Badge](https://img.shields.io/badge/internal-blue?style=flat) ![Static Badge](https://img.shields.io/badge/nightly-yellow?style=flat) ![Static Badge](https://img.shields.io/badge/azure_devops-grey?style=flat) : +> - [![Static Badge](https://img.shields.io/badge/azure_devops-grey?style=flat)](https://dev.azure.com/AGPublish/AGPublic/_artifacts/feed/AutoGen-Nightly) : Firstly, following the [installation guide](./website/articles/Installation.md) to install AutoGen packages. diff --git a/dotnet/eng/MetaInfo.props b/dotnet/eng/MetaInfo.props index 4f3d216aa08c..db46778a06c2 100644 --- a/dotnet/eng/MetaInfo.props +++ b/dotnet/eng/MetaInfo.props @@ -1,7 +1,8 @@ - 0.2.2 + 0.4.0 + 0.2.2 AutoGen https://microsoft.github.io/autogen-for-net/ https://github.com/microsoft/autogen diff --git a/dotnet/global.json b/dotnet/global.json index 5f78cce063fa..4f9e9b79a15a 100644 --- a/dotnet/global.json +++ b/dotnet/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.104", + "version": "8.0.401", "rollForward": "latestMinor" } } diff --git a/dotnet/nuget/README.md b/dotnet/nuget/README.md new file mode 100644 index 000000000000..c95a97624788 --- /dev/null +++ b/dotnet/nuget/README.md @@ -0,0 +1,13 @@ +# NuGet Directory + +This directory contains resources and metadata for packaging the AutoGen.NET SDK as a NuGet package. + +## Files + +- **icon.png**: The icon used for the NuGet package. +- **NUGET.md**: The readme file displayed on the NuGet package page. +- **NUGET-PACKAGE.PROPS**: The MSBuild properties file that defines the packaging settings for the NuGet package. + +## Purpose + +The files in this directory are used to configure and build the NuGet package for the AutoGen.NET SDK, ensuring that it includes necessary metadata, documentation, and resources. \ No newline at end of file diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index 50cbafc73b66..380380794dc9 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -7,7 +7,7 @@ Microsoft AutoGen A programming framework for agentic AI - AI, Artificial Intelligence, SDK + AI, Artificial Intelligence, Agents, Multiagent, SDK $(AssemblyName) diff --git a/dotnet/samples/Hello/Backend/Backend.csproj b/dotnet/samples/Hello/Backend/Backend.csproj index d502d7260d15..360459334805 100644 --- a/dotnet/samples/Hello/Backend/Backend.csproj +++ b/dotnet/samples/Hello/Backend/Backend.csproj @@ -1,4 +1,4 @@ - + diff --git a/dotnet/samples/Hello/Backend/Program.cs b/dotnet/samples/Hello/Backend/Program.cs index b913d39d643f..b74dba139826 100644 --- a/dotnet/samples/Hello/Backend/Program.cs +++ b/dotnet/samples/Hello/Backend/Program.cs @@ -1,7 +1,5 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Program.cs -using Microsoft.Extensions.Hosting; - var app = await Microsoft.AutoGen.Agents.Host.StartAsync(local: false, useGrpc: true); await app.WaitForShutdownAsync(); diff --git a/dotnet/samples/Hello/Backend/appsettings.json b/dotnet/samples/Hello/Backend/appsettings.json index 3bb8d882550c..ae32fe371a70 100644 --- a/dotnet/samples/Hello/Backend/appsettings.json +++ b/dotnet/samples/Hello/Backend/appsettings.json @@ -1,9 +1,15 @@ { - "Logging": { - "LogLevel": { - "Default": "Warning", - "Microsoft": "Warning", - "Microsoft.Orleans": "Warning" - } + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft": "Warning", + "Microsoft.Orleans": "Warning" } - } \ No newline at end of file + }, + "AllowedHosts": "*", + "Kestrel": { + "EndpointDefaults": { + "Protocols": "Http2" + } + } +} diff --git a/dotnet/samples/Hello/Hello.AppHost/Hello.AppHost.csproj b/dotnet/samples/Hello/Hello.AppHost/Hello.AppHost.csproj index 88d23268c44d..5ce0d0531faf 100644 --- a/dotnet/samples/Hello/Hello.AppHost/Hello.AppHost.csproj +++ b/dotnet/samples/Hello/Hello.AppHost/Hello.AppHost.csproj @@ -1,7 +1,10 @@ + + + Exe - net8.0 + net8.0 enable enable true diff --git a/dotnet/samples/Hello/Hello.AppHost/Program.cs b/dotnet/samples/Hello/Hello.AppHost/Program.cs index d9acc3ea3f12..326eddbcc9ec 100644 --- a/dotnet/samples/Hello/Hello.AppHost/Program.cs +++ b/dotnet/samples/Hello/Hello.AppHost/Program.cs @@ -1,7 +1,19 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Program.cs +using Microsoft.Extensions.Hosting; + var builder = DistributedApplication.CreateBuilder(args); -var backend = builder.AddProject("backend"); -builder.AddProject("client").WithReference(backend).WaitFor(backend); -builder.Build().Run(); +var backend = builder.AddProject("backend").WithExternalHttpEndpoints(); +builder.AddProject("client") + .WithReference(backend) + .WithEnvironment("AGENT_HOST", $"{backend.GetEndpoint("https").Property(EndpointProperty.Url)}") + .WaitFor(backend); + +using var app = builder.Build(); + +await app.StartAsync(); +var url = backend.GetEndpoint("http").Url; +Console.WriteLine("Backend URL: " + url); + +await app.WaitForShutdownAsync(); diff --git a/dotnet/samples/Hello/HelloAIAgents/HelloAIAgents.csproj b/dotnet/samples/Hello/HelloAIAgents/HelloAIAgents.csproj index f17ab0c9f0a5..c33bfeed5a8d 100644 --- a/dotnet/samples/Hello/HelloAIAgents/HelloAIAgents.csproj +++ b/dotnet/samples/Hello/HelloAIAgents/HelloAIAgents.csproj @@ -1,7 +1,7 @@ Exe - net8.0 + net8.0 enable enable @@ -13,6 +13,6 @@ - + diff --git a/dotnet/samples/Hello/HelloAgent/HelloAgent.csproj b/dotnet/samples/Hello/HelloAgent/HelloAgent.csproj index f2f3e473fef7..93c996e32093 100644 --- a/dotnet/samples/Hello/HelloAgent/HelloAgent.csproj +++ b/dotnet/samples/Hello/HelloAgent/HelloAgent.csproj @@ -1,14 +1,17 @@ - + Exe - net8.0 + net8.0 enable enable - + + + PreserveNewest + + - diff --git a/dotnet/samples/Hello/HelloAgent/Program.cs b/dotnet/samples/Hello/HelloAgent/Program.cs index 506d91502328..4f74520a71e0 100644 --- a/dotnet/samples/Hello/HelloAgent/Program.cs +++ b/dotnet/samples/Hello/HelloAgent/Program.cs @@ -19,7 +19,7 @@ { Message = "World" }, local: true); - +//var app = await AgentsApp.StartAsync(); await app.WaitForShutdownAsync(); namespace Hello @@ -33,7 +33,8 @@ public class HelloAgent( ISayHello, IHandleConsole, IHandle, - IHandle + IHandle, + IHandle { public async Task Handle(NewMessageReceived item) { @@ -50,13 +51,14 @@ public async Task Handle(NewMessageReceived item) public async Task Handle(ConversationClosed item) { var goodbye = $"********************* {item.UserId} said {item.UserMessage} ************************"; - var evt = new Output - { - Message = goodbye - }; - await PublishMessageAsync(evt).ConfigureAwait(false); + var evt = new Output { Message = goodbye }; + await PublishMessageAsync(evt).ConfigureAwait(true); + await PublishMessageAsync(new Shutdown()).ConfigureAwait(false); + } - // Signal shutdown. + public async Task Handle(Shutdown item) + { + Console.WriteLine("Shutting down..."); hostApplicationLifetime.StopApplication(); } diff --git a/dotnet/samples/Hello/HelloAgentState/HelloAgentState.csproj b/dotnet/samples/Hello/HelloAgentState/HelloAgentState.csproj index 797fe957bb75..e26b6c9521c2 100644 --- a/dotnet/samples/Hello/HelloAgentState/HelloAgentState.csproj +++ b/dotnet/samples/Hello/HelloAgentState/HelloAgentState.csproj @@ -1,7 +1,7 @@ Exe - net8.0 + net8.0 enable enable diff --git a/dotnet/samples/Hello/HelloAgentState/Program.cs b/dotnet/samples/Hello/HelloAgentState/Program.cs index 7c15c4c54df0..664689de824d 100644 --- a/dotnet/samples/Hello/HelloAgentState/Program.cs +++ b/dotnet/samples/Hello/HelloAgentState/Program.cs @@ -9,7 +9,7 @@ var app = await AgentsApp.PublishMessageAsync("HelloAgents", new NewMessageReceived { Message = "World" -}, local: true); +}, local: false); await app.WaitForShutdownAsync(); diff --git a/dotnet/samples/dev-team/DevTeam.AgentHost/DevTeam.AgentHost.csproj b/dotnet/samples/dev-team/DevTeam.AgentHost/DevTeam.AgentHost.csproj index d8d7ebf8e48b..7508ae5af56e 100644 --- a/dotnet/samples/dev-team/DevTeam.AgentHost/DevTeam.AgentHost.csproj +++ b/dotnet/samples/dev-team/DevTeam.AgentHost/DevTeam.AgentHost.csproj @@ -1,7 +1,7 @@ - net8.0 + net8.0 enable enable @@ -10,7 +10,7 @@ - + diff --git a/dotnet/samples/dev-team/DevTeam.Agents/DevTeam.Agents.csproj b/dotnet/samples/dev-team/DevTeam.Agents/DevTeam.Agents.csproj index 8dfd6912e547..46a20c650fb7 100644 --- a/dotnet/samples/dev-team/DevTeam.Agents/DevTeam.Agents.csproj +++ b/dotnet/samples/dev-team/DevTeam.Agents/DevTeam.Agents.csproj @@ -1,7 +1,7 @@ - net8.0 + net8.0 enable enable @@ -10,7 +10,7 @@ - + diff --git a/dotnet/samples/dev-team/DevTeam.AppHost/DevTeam.AppHost.csproj b/dotnet/samples/dev-team/DevTeam.AppHost/DevTeam.AppHost.csproj index a9227ea9516d..89d121b303ea 100644 --- a/dotnet/samples/dev-team/DevTeam.AppHost/DevTeam.AppHost.csproj +++ b/dotnet/samples/dev-team/DevTeam.AppHost/DevTeam.AppHost.csproj @@ -1,8 +1,10 @@ + + Exe - net8.0 + net8.0 enable enable true diff --git a/dotnet/samples/dev-team/DevTeam.Backend/DevTeam.Backend.csproj b/dotnet/samples/dev-team/DevTeam.Backend/DevTeam.Backend.csproj index 8296f7aa670e..10e05cfb2107 100644 --- a/dotnet/samples/dev-team/DevTeam.Backend/DevTeam.Backend.csproj +++ b/dotnet/samples/dev-team/DevTeam.Backend/DevTeam.Backend.csproj @@ -5,7 +5,7 @@ - net8.0 + net8.0 enable enable @@ -29,7 +29,7 @@ - + diff --git a/dotnet/samples/dev-team/DevTeam.Backend/Program.cs b/dotnet/samples/dev-team/DevTeam.Backend/Program.cs index b24476692d08..7f4404f8e471 100644 --- a/dotnet/samples/dev-team/DevTeam.Backend/Program.cs +++ b/dotnet/samples/dev-team/DevTeam.Backend/Program.cs @@ -67,9 +67,9 @@ }); ; app.UseSwagger(); -app.UseSwaggerUI(c => +/* app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1"); -}); +}); */ app.Run(); diff --git a/dotnet/samples/dev-team/DevTeam.Shared/DevTeam.Shared.csproj b/dotnet/samples/dev-team/DevTeam.Shared/DevTeam.Shared.csproj index bc739135da95..18fcb9745238 100644 --- a/dotnet/samples/dev-team/DevTeam.Shared/DevTeam.Shared.csproj +++ b/dotnet/samples/dev-team/DevTeam.Shared/DevTeam.Shared.csproj @@ -5,7 +5,7 @@ - net8.0 + net8.0 enable enable diff --git a/dotnet/samples/dev-team/dev team.sln b/dotnet/samples/dev-team/dev team.sln new file mode 100644 index 000000000000..f8a7aeacd924 --- /dev/null +++ b/dotnet/samples/dev-team/dev team.sln @@ -0,0 +1,49 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.11.35327.3 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevTeam.AgentHost", "DevTeam.AgentHost\DevTeam.AgentHost.csproj", "{A6FC8B01-A177-4690-BD16-73EE3D0C06A0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevTeam.Backend", "DevTeam.Backend\DevTeam.Backend.csproj", "{2D4BAD10-85F3-4E4B-B759-13449A212A96}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevTeam.Agents", "DevTeam.Agents\DevTeam.Agents.csproj", "{A51CE540-72B0-4271-B63D-A30CAB61C227}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevTeam.AppHost", "DevTeam.AppHost\DevTeam.AppHost.csproj", "{2B8A3C64-9F4E-4CC5-9466-AFFD8E676D2E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevTeam.Shared", "DevTeam.Shared\DevTeam.Shared.csproj", "{557701A5-35D8-4CE3-BA75-D5412B4227F5}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A6FC8B01-A177-4690-BD16-73EE3D0C06A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A6FC8B01-A177-4690-BD16-73EE3D0C06A0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6FC8B01-A177-4690-BD16-73EE3D0C06A0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A6FC8B01-A177-4690-BD16-73EE3D0C06A0}.Release|Any CPU.Build.0 = Release|Any CPU + {2D4BAD10-85F3-4E4B-B759-13449A212A96}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2D4BAD10-85F3-4E4B-B759-13449A212A96}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2D4BAD10-85F3-4E4B-B759-13449A212A96}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2D4BAD10-85F3-4E4B-B759-13449A212A96}.Release|Any CPU.Build.0 = Release|Any CPU + {A51CE540-72B0-4271-B63D-A30CAB61C227}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A51CE540-72B0-4271-B63D-A30CAB61C227}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A51CE540-72B0-4271-B63D-A30CAB61C227}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A51CE540-72B0-4271-B63D-A30CAB61C227}.Release|Any CPU.Build.0 = Release|Any CPU + {2B8A3C64-9F4E-4CC5-9466-AFFD8E676D2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2B8A3C64-9F4E-4CC5-9466-AFFD8E676D2E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2B8A3C64-9F4E-4CC5-9466-AFFD8E676D2E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2B8A3C64-9F4E-4CC5-9466-AFFD8E676D2E}.Release|Any CPU.Build.0 = Release|Any CPU + {557701A5-35D8-4CE3-BA75-D5412B4227F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {557701A5-35D8-4CE3-BA75-D5412B4227F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {557701A5-35D8-4CE3-BA75-D5412B4227F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {557701A5-35D8-4CE3-BA75-D5412B4227F5}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {DE04DB59-B8CD-4305-875B-E71442345CCF} + EndGlobalSection +EndGlobal diff --git a/dotnet/src/AutoGen.Core/Function/FunctionAttribute.cs b/dotnet/src/AutoGen.Core/Function/FunctionAttribute.cs index 9418dc7fd6ae..9367f5c6f297 100644 --- a/dotnet/src/AutoGen.Core/Function/FunctionAttribute.cs +++ b/dotnet/src/AutoGen.Core/Function/FunctionAttribute.cs @@ -98,7 +98,7 @@ public static implicit operator AIFunctionMetadata(FunctionContract contract) [NamespaceKey] = contract.Namespace, [ClassNameKey] = contract.ClassName, }, - Parameters = [.. contract.Parameters?.Select(p => (AIFunctionParameterMetadata)p)], + Parameters = [.. contract.Parameters?.Select(p => (AIFunctionParameterMetadata)p)!], }; } } diff --git a/dotnet/src/AutoGen.Core/Message/ImageMessage.cs b/dotnet/src/AutoGen.Core/Message/ImageMessage.cs index 369a3782d502..37be3a7c7ed1 100644 --- a/dotnet/src/AutoGen.Core/Message/ImageMessage.cs +++ b/dotnet/src/AutoGen.Core/Message/ImageMessage.cs @@ -2,39 +2,65 @@ // ImageMessage.cs using System; +using System.Text.RegularExpressions; namespace AutoGen.Core; public class ImageMessage : IMessage { - public ImageMessage(Role role, string url, string? from = null, string? mimeType = null) - : this(role, new Uri(url), from, mimeType) - { - } + private static readonly Regex s_DataUriRegex = new Regex(@"^data:(?[^;]+);base64,(?.*)$", RegexOptions.Compiled); - public ImageMessage(Role role, Uri uri, string? from = null, string? mimeType = null) + /// + /// Create an ImageMessage from a url. + /// The url can be a regular url or a data uri. + /// If the url is a data uri, the scheme must be "data" and the format must be data:[][;base64], + /// + public ImageMessage(Role role, string url, string? from = null, string? mimeType = null) { this.Role = role; this.From = from; - this.Url = uri.ToString(); - // try infer mimeType from uri extension if not provided - if (mimeType is null) + // url might be a data uri or a regular url + if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + // the url must be in the format of data:[][;base64], + var match = s_DataUriRegex.Match(url); + + if (!match.Success) + { + throw new ArgumentException("Invalid DataUri format, expected data:[][;base64],", nameof(url)); + } + + this.Data = new BinaryData(Convert.FromBase64String(match.Groups["data"].Value), match.Groups["mediatype"].Value); + + this.MimeType = match.Groups["mediatype"].Value; + } + else { - mimeType = uri switch + this.Url = url; + // try infer mimeType from uri extension if not provided + if (mimeType is null) { - _ when uri.AbsoluteUri.EndsWith(".png", StringComparison.OrdinalIgnoreCase) => "image/png", - _ when uri.AbsoluteUri.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) => "image/jpeg", - _ when uri.AbsoluteUri.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) => "image/jpeg", - _ when uri.AbsoluteUri.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) => "image/gif", - _ when uri.AbsoluteUri.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase) => "image/bmp", - _ when uri.AbsoluteUri.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) => "image/webp", - _ when uri.AbsoluteUri.EndsWith(".svg", StringComparison.OrdinalIgnoreCase) => "image/svg+xml", - _ => throw new ArgumentException("MimeType is required for ImageMessage", nameof(mimeType)) - }; + mimeType = url switch + { + _ when url.EndsWith(".png", StringComparison.OrdinalIgnoreCase) => "image/png", + _ when url.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) => "image/jpeg", + _ when url.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) => "image/jpeg", + _ when url.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) => "image/gif", + _ when url.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase) => "image/bmp", + _ when url.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) => "image/webp", + _ when url.EndsWith(".svg", StringComparison.OrdinalIgnoreCase) => "image/svg+xml", + _ => throw new ArgumentException("MimeType is required for ImageMessage", nameof(mimeType)) + }; + } + + this.MimeType = mimeType; } + } - this.MimeType = mimeType; + public ImageMessage(Role role, Uri uri, string? from = null, string? mimeType = null) + : this(role, uri.ToString(), from, mimeType) + { } public ImageMessage(Role role, BinaryData data, string? from = null) diff --git a/dotnet/src/AutoGen.OpenAI/AutoGen.OpenAI.csproj b/dotnet/src/AutoGen.OpenAI/AutoGen.OpenAI.csproj index 7f00b63be86c..70c0f2b0d1ce 100644 --- a/dotnet/src/AutoGen.OpenAI/AutoGen.OpenAI.csproj +++ b/dotnet/src/AutoGen.OpenAI/AutoGen.OpenAI.csproj @@ -18,6 +18,7 @@ + diff --git a/dotnet/src/AutoGen.OpenAI/Orchestrator/RolePlayToolCallOrchestrator.cs b/dotnet/src/AutoGen.OpenAI/Orchestrator/RolePlayToolCallOrchestrator.cs new file mode 100644 index 000000000000..f088e1748e66 --- /dev/null +++ b/dotnet/src/AutoGen.OpenAI/Orchestrator/RolePlayToolCallOrchestrator.cs @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// RolePlayToolCallOrchestrator.cs + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AutoGen.OpenAI.Extension; +using OpenAI.Chat; + +namespace AutoGen.OpenAI.Orchestrator; + +/// +/// Orchestrating group chat using role play tool call +/// +public partial class RolePlayToolCallOrchestrator : IOrchestrator +{ + public readonly ChatClient chatClient; + private readonly Graph? workflow; + + public RolePlayToolCallOrchestrator(ChatClient chatClient, Graph? workflow = null) + { + this.chatClient = chatClient; + this.workflow = workflow; + } + + public async Task GetNextSpeakerAsync( + OrchestrationContext context, + CancellationToken cancellationToken = default) + { + var candidates = context.Candidates.ToList(); + + if (candidates.Count == 0) + { + return null; + } + + if (candidates.Count == 1) + { + return candidates.First(); + } + + // if there's a workflow + // and the next available agent from the workflow is in the group chat + // then return the next agent from the workflow + if (this.workflow != null) + { + var lastMessage = context.ChatHistory.LastOrDefault(); + if (lastMessage == null) + { + return null; + } + var currentSpeaker = candidates.First(candidates => candidates.Name == lastMessage.From); + var nextAgents = await this.workflow.TransitToNextAvailableAgentsAsync(currentSpeaker, context.ChatHistory, cancellationToken); + nextAgents = nextAgents.Where(nextAgent => candidates.Any(candidate => candidate.Name == nextAgent.Name)); + candidates = nextAgents.ToList(); + if (!candidates.Any()) + { + return null; + } + + if (candidates is { Count: 1 }) + { + return candidates.First(); + } + } + + // In this case, since there are more than one available agents from the workflow for the next speaker + // We need to invoke LLM to select the next speaker via select next speaker function + + var chatHistoryStringBuilder = new StringBuilder(); + foreach (var message in context.ChatHistory) + { + var chatHistoryPrompt = $"{message.From}: {message.GetContent()}"; + + chatHistoryStringBuilder.AppendLine(chatHistoryPrompt); + } + + var chatHistory = chatHistoryStringBuilder.ToString(); + + var prompt = $""" + # Task: Select the next speaker + + You are in a role-play game. Carefully read the conversation history and select the next speaker from the available roles. + + # Conversation + {chatHistory} + + # Available roles + - {string.Join(",", candidates.Select(candidate => candidate.Name))} + + Select the next speaker from the available roles and provide a reason for your selection. + """; + + // enforce the next speaker to be selected by the LLM + var option = new ChatCompletionOptions + { + ToolChoice = ChatToolChoice.CreateFunctionChoice(this.SelectNextSpeakerFunctionContract.Name), + }; + + option.Tools.Add(this.SelectNextSpeakerFunctionContract.ToChatTool()); + var toolCallMiddleware = new FunctionCallMiddleware( + functions: [this.SelectNextSpeakerFunctionContract], + functionMap: new Dictionary>> + { + [this.SelectNextSpeakerFunctionContract.Name] = this.SelectNextSpeakerWrapper, + }); + + var selectAgent = new OpenAIChatAgent( + chatClient, + "admin", + option) + .RegisterMessageConnector() + .RegisterMiddleware(toolCallMiddleware); + + var reply = await selectAgent.SendAsync(prompt); + + var nextSpeaker = candidates.FirstOrDefault(candidate => candidate.Name == reply.GetContent()); + + return nextSpeaker; + } + + /// + /// Select the next speaker by name and reason + /// + [Function] + public async Task SelectNextSpeaker(string name, string reason) + { + return name; + } +} diff --git a/dotnet/src/AutoGen.SemanticKernel/Middleware/SemanticKernelChatMessageContentConnector.cs b/dotnet/src/AutoGen.SemanticKernel/Middleware/SemanticKernelChatMessageContentConnector.cs index 073709ebad09..92947092ba28 100644 --- a/dotnet/src/AutoGen.SemanticKernel/Middleware/SemanticKernelChatMessageContentConnector.cs +++ b/dotnet/src/AutoGen.SemanticKernel/Middleware/SemanticKernelChatMessageContentConnector.cs @@ -181,7 +181,19 @@ private IEnumerable ProcessMessageForOthers(TextMessage mess private IEnumerable ProcessMessageForOthers(ImageMessage message) { var collectionItems = new ChatMessageContentItemCollection(); - collectionItems.Add(new ImageContent(new Uri(message.Url ?? message.BuildDataUri()))); + if (message.Url is not null) + { + collectionItems.Add(new ImageContent(new Uri(message.Url))); + } + else if (message.BuildDataUri() is string dataUri) + { + collectionItems.Add(new ImageContent(dataUri)); + } + else + { + throw new InvalidOperationException("ImageMessage must have Url or DataUri"); + } + return [new ChatMessageContent(AuthorRole.User, collectionItems)]; } diff --git a/dotnet/src/Microsoft.AutoGen/Abstractions/IAgentBase.cs b/dotnet/src/Microsoft.AutoGen/Abstractions/IAgentBase.cs index 14c2688c236a..ee7b9e74583c 100644 --- a/dotnet/src/Microsoft.AutoGen/Abstractions/IAgentBase.cs +++ b/dotnet/src/Microsoft.AutoGen/Abstractions/IAgentBase.cs @@ -19,4 +19,5 @@ public interface IAgentBase Task ReadAsync(AgentId agentId, CancellationToken cancellationToken = default) where T : IMessage, new(); ValueTask PublishEventAsync(CloudEvent item, CancellationToken cancellationToken = default); ValueTask PublishEventAsync(string topic, IMessage evt, CancellationToken cancellationToken = default); + List Subscribe(string topic); } diff --git a/dotnet/src/Microsoft.AutoGen/Abstractions/IAgentRuntime.cs b/dotnet/src/Microsoft.AutoGen/Abstractions/IAgentRuntime.cs index 2125e57a8b96..6b3d4f98cdb2 100644 --- a/dotnet/src/Microsoft.AutoGen/Abstractions/IAgentRuntime.cs +++ b/dotnet/src/Microsoft.AutoGen/Abstractions/IAgentRuntime.cs @@ -13,9 +13,10 @@ public interface IAgentRuntime ValueTask ReadAsync(AgentId agentId, CancellationToken cancellationToken = default); ValueTask SendResponseAsync(RpcRequest request, RpcResponse response, CancellationToken cancellationToken = default); ValueTask SendRequestAsync(IAgentBase agent, RpcRequest request, CancellationToken cancellationToken = default); + ValueTask SendMessageAsync(Message message, CancellationToken cancellationToken = default); ValueTask PublishEventAsync(CloudEvent @event, CancellationToken cancellationToken = default); - void Update(Activity? activity, RpcRequest request); - void Update(Activity? activity, CloudEvent cloudEvent); - (string?, string?) GetTraceIDandState(IDictionary metadata); + void Update(RpcRequest request, Activity? activity); + void Update(CloudEvent cloudEvent, Activity? activity); + (string?, string?) GetTraceIdAndState(IDictionary metadata); IDictionary ExtractMetadata(IDictionary metadata); } diff --git a/dotnet/src/Microsoft.AutoGen/Abstractions/IAgentState.cs b/dotnet/src/Microsoft.AutoGen/Abstractions/IAgentState.cs index 0a6784b54fd3..1b816b4ef3ad 100644 --- a/dotnet/src/Microsoft.AutoGen/Abstractions/IAgentState.cs +++ b/dotnet/src/Microsoft.AutoGen/Abstractions/IAgentState.cs @@ -3,8 +3,24 @@ namespace Microsoft.AutoGen.Abstractions; +/// +/// Interface for managing the state of an agent. +/// public interface IAgentState { - ValueTask ReadStateAsync(); - ValueTask WriteStateAsync(AgentState state, string eTag); + /// + /// Reads the current state of the agent asynchronously. + /// + /// A token to cancel the operation. + /// A task that represents the asynchronous read operation. The task result contains the current state of the agent. + ValueTask ReadStateAsync(CancellationToken cancellationToken = default); + + /// + /// Writes the specified state of the agent asynchronously. + /// + /// The state to write. + /// The ETag for concurrency control. + /// A token to cancel the operation. + /// A task that represents the asynchronous write operation. The task result contains the ETag of the written state. + ValueTask WriteStateAsync(AgentState state, string eTag, CancellationToken cancellationToken = default); } diff --git a/dotnet/src/Microsoft.AutoGen/Abstractions/IAgentWorker.cs b/dotnet/src/Microsoft.AutoGen/Abstractions/IAgentWorker.cs index 67a867d87dfa..adce9be60c9e 100644 --- a/dotnet/src/Microsoft.AutoGen/Abstractions/IAgentWorker.cs +++ b/dotnet/src/Microsoft.AutoGen/Abstractions/IAgentWorker.cs @@ -8,6 +8,7 @@ public interface IAgentWorker ValueTask PublishEventAsync(CloudEvent evt, CancellationToken cancellationToken = default); ValueTask SendRequestAsync(IAgentBase agent, RpcRequest request, CancellationToken cancellationToken = default); ValueTask SendResponseAsync(RpcResponse response, CancellationToken cancellationToken = default); + ValueTask SendMessageAsync(Message message, CancellationToken cancellationToken = default); ValueTask StoreAsync(AgentState value, CancellationToken cancellationToken = default); ValueTask ReadAsync(AgentId agentId, CancellationToken cancellationToken = default); } diff --git a/dotnet/src/Microsoft.AutoGen/Abstractions/Microsoft.AutoGen.Abstractions.csproj b/dotnet/src/Microsoft.AutoGen/Abstractions/Microsoft.AutoGen.Abstractions.csproj index e24b52187c82..39a90664057e 100644 --- a/dotnet/src/Microsoft.AutoGen/Abstractions/Microsoft.AutoGen.Abstractions.csproj +++ b/dotnet/src/Microsoft.AutoGen/Abstractions/Microsoft.AutoGen.Abstractions.csproj @@ -4,12 +4,10 @@ net8.0 enable enable - AutoGen.Core - https://github.com/microsoft/agnext - Microsoft - AutoGenn Core Library + + @@ -20,8 +18,6 @@ - - diff --git a/dotnet/src/Microsoft.AutoGen/Agents/AgentBase.cs b/dotnet/src/Microsoft.AutoGen/Agents/AgentBase.cs index 6fffdaadf1d8..01ad856a2d49 100644 --- a/dotnet/src/Microsoft.AutoGen/Agents/AgentBase.cs +++ b/dotnet/src/Microsoft.AutoGen/Agents/AgentBase.cs @@ -15,27 +15,40 @@ namespace Microsoft.AutoGen.Agents; public abstract class AgentBase : IAgentBase, IHandle { public static readonly ActivitySource s_source = new("AutoGen.Agent"); - public AgentId AgentId => _context.AgentId; + public AgentId AgentId => _runtime.AgentId; private readonly object _lock = new(); private readonly Dictionary> _pendingRequests = []; private readonly Channel _mailbox = Channel.CreateUnbounded(); - private readonly IAgentRuntime _context; + private readonly IAgentRuntime _runtime; public string Route { get; set; } = "base"; protected internal ILogger _logger; - public IAgentRuntime Context => _context; + public IAgentRuntime Context => _runtime; protected readonly EventTypes EventTypes; protected AgentBase( - IAgentRuntime context, + IAgentRuntime runtime, EventTypes eventTypes, ILogger? logger = null) { - _context = context; - context.AgentInstance = this; + _runtime = runtime; + runtime.AgentInstance = this; this.EventTypes = eventTypes; _logger = logger ?? LoggerFactory.Create(builder => { }).CreateLogger(); + var subscriptionRequest = new AddSubscriptionRequest + { + RequestId = Guid.NewGuid().ToString(), + Subscription = new Subscription + { + TypeSubscription = new TypeSubscription + { + AgentType = this.AgentId.Type, + TopicType = this.AgentId.Type + "/" + this.AgentId.Key + } + } + }; + _runtime.SendMessageAsync(new Message { AddSubscriptionRequest = subscriptionRequest }).AsTask().Wait(); Completion = Start(); } internal Task Completion { get; } @@ -93,7 +106,7 @@ protected internal async Task HandleRpcMessage(Message msg, CancellationToken ca { var activity = this.ExtractActivity(msg.CloudEvent.Type, msg.CloudEvent.Metadata); await this.InvokeWithActivityAsync( - static ((AgentBase Agent, CloudEvent Item) state) => state.Agent.CallHandler(state.Item), + static ((AgentBase Agent, CloudEvent Item) state, CancellationToken _) => state.Agent.CallHandler(state.Item), (this, msg.CloudEvent), activity, msg.CloudEvent.Type, cancellationToken).ConfigureAwait(false); @@ -103,7 +116,7 @@ await this.InvokeWithActivityAsync( { var activity = this.ExtractActivity(msg.Request.Method, msg.Request.Metadata); await this.InvokeWithActivityAsync( - static ((AgentBase Agent, RpcRequest Request) state) => state.Agent.OnRequestCoreAsync(state.Request), + static ((AgentBase Agent, RpcRequest Request) state, CancellationToken ct) => state.Agent.OnRequestCoreAsync(state.Request, ct), (this, msg.Request), activity, msg.Request.Method, cancellationToken).ConfigureAwait(false); @@ -114,14 +127,35 @@ await this.InvokeWithActivityAsync( break; } } + public List Subscribe(string topic) + { + Message message = new() + { + AddSubscriptionRequest = new() + { + RequestId = Guid.NewGuid().ToString(), + Subscription = new Subscription + { + TypeSubscription = new TypeSubscription + { + TopicType = topic, + AgentType = this.AgentId.Key + } + } + } + }; + _runtime.SendMessageAsync(message).AsTask().Wait(); + + return new List { topic }; + } public async Task StoreAsync(AgentState state, CancellationToken cancellationToken = default) { - await _context.StoreAsync(state, cancellationToken).ConfigureAwait(false); + await _runtime.StoreAsync(state, cancellationToken).ConfigureAwait(false); return; } public async Task ReadAsync(AgentId agentId, CancellationToken cancellationToken = default) where T : IMessage, new() { - var agentstate = await _context.ReadAsync(agentId, cancellationToken).ConfigureAwait(false); + var agentstate = await _runtime.ReadAsync(agentId, cancellationToken).ConfigureAwait(false); return agentstate.FromAgentState(); } private void OnResponseCore(RpcResponse response) @@ -150,7 +184,7 @@ private async Task OnRequestCoreAsync(RpcRequest request, CancellationToken canc { response = new RpcResponse { Error = ex.Message }; } - await _context.SendResponseAsync(request, response, cancellationToken).ConfigureAwait(false); + await _runtime.SendResponseAsync(request, response, cancellationToken).ConfigureAwait(false); } protected async Task RequestAsync(AgentId target, string method, Dictionary parameters) @@ -174,9 +208,9 @@ protected async Task RequestAsync(AgentId target, string method, Di activity?.SetTag("peer.service", target.ToString()); var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _context.Update(activity, request); + _runtime.Update(request, activity); await this.InvokeWithActivityAsync( - static async ((AgentBase Agent, RpcRequest Request, TaskCompletionSource) state) => + static async ((AgentBase Agent, RpcRequest Request, TaskCompletionSource) state, CancellationToken ct) => { var (self, request, completion) = state; @@ -185,7 +219,7 @@ static async ((AgentBase Agent, RpcRequest Request, TaskCompletionSource + static async ((AgentBase Agent, CloudEvent Event) state, CancellationToken ct) => { - await state.Agent._context.PublishEventAsync(state.Event).ConfigureAwait(false); + await state.Agent._runtime.PublishEventAsync(state.Event).ConfigureAwait(false); }, (this, item), activity, diff --git a/dotnet/src/Microsoft.AutoGen/Agents/AgentBaseExtensions.cs b/dotnet/src/Microsoft.AutoGen/Agents/AgentBaseExtensions.cs index ce1318a0d332..5d738e5fc383 100644 --- a/dotnet/src/Microsoft.AutoGen/Agents/AgentBaseExtensions.cs +++ b/dotnet/src/Microsoft.AutoGen/Agents/AgentBaseExtensions.cs @@ -5,15 +5,25 @@ namespace Microsoft.AutoGen.Agents; +/// +/// Provides extension methods for the class. +/// public static class AgentBaseExtensions { + /// + /// Extracts an from the given agent and metadata. + /// + /// The agent from which to extract the activity. + /// The name of the activity. + /// The metadata containing trace information. + /// The extracted or null if extraction fails. public static Activity? ExtractActivity(this AgentBase agent, string activityName, IDictionary metadata) { Activity? activity; - (var traceParent, var traceState) = agent.Context.GetTraceIDandState(metadata); + var (traceParent, traceState) = agent.Context.GetTraceIdAndState(metadata); if (!string.IsNullOrEmpty(traceParent)) { - if (ActivityContext.TryParse(traceParent, traceState, isRemote: true, out ActivityContext parentContext)) + if (ActivityContext.TryParse(traceParent, traceState, isRemote: true, out var parentContext)) { // traceParent is a W3CId activity = AgentBase.s_source.CreateActivity(activityName, ActivityKind.Server, parentContext); @@ -33,12 +43,9 @@ public static class AgentBaseExtensions var baggage = agent.Context.ExtractMetadata(metadata); - if (baggage is not null) + foreach (var baggageItem in baggage) { - foreach (var baggageItem in baggage) - { - activity.AddBaggage(baggageItem.Key, baggageItem.Value); - } + activity.AddBaggage(baggageItem.Key, baggageItem.Value); } } } @@ -49,7 +56,19 @@ public static class AgentBaseExtensions return activity; } - public static async Task InvokeWithActivityAsync(this AgentBase agent, Func func, TState state, Activity? activity, string methodName, CancellationToken cancellationToken = default) + + /// + /// Invokes a function asynchronously within the context of an . + /// + /// The type of the state parameter. + /// The agent invoking the function. + /// The function to invoke. + /// The state parameter to pass to the function. + /// The activity within which to invoke the function. + /// The name of the method being invoked. + /// A token to monitor for cancellation requests. + /// A task representing the asynchronous operation. + public static async Task InvokeWithActivityAsync(this AgentBase agent, Func func, TState state, Activity? activity, string methodName, CancellationToken cancellationToken = default) { if (activity is not null && activity.StartTimeUtc == default) { @@ -63,7 +82,7 @@ public static async Task InvokeWithActivityAsync(this AgentBase agent, F try { - await func(state).ConfigureAwait(false); + await func(state, cancellationToken).ConfigureAwait(false); if (activity is not null && activity.IsAllDataRequested) { activity.SetStatus(ActivityStatusCode.Ok); diff --git a/dotnet/src/Microsoft.AutoGen/Agents/AgentRuntime.cs b/dotnet/src/Microsoft.AutoGen/Agents/AgentRuntime.cs index 86944cad3ab3..c36d456af32e 100644 --- a/dotnet/src/Microsoft.AutoGen/Agents/AgentRuntime.cs +++ b/dotnet/src/Microsoft.AutoGen/Agents/AgentRuntime.cs @@ -15,7 +15,7 @@ internal sealed class AgentRuntime(AgentId agentId, IAgentWorker worker, ILogger public ILogger Logger { get; } = logger; public IAgentBase? AgentInstance { get; set; } private DistributedContextPropagator DistributedContextPropagator { get; } = distributedContextPropagator; - public (string?, string?) GetTraceIDandState(IDictionary metadata) + public (string?, string?) GetTraceIdAndState(IDictionary metadata) { DistributedContextPropagator.ExtractTraceIdAndState(metadata, static (object? carrier, string fieldName, out string? fieldValue, out IEnumerable? fieldValues) => @@ -28,11 +28,11 @@ internal sealed class AgentRuntime(AgentId agentId, IAgentWorker worker, ILogger out var traceState); return (traceParent, traceState); } - public void Update(Activity? activity, RpcRequest request) + public void Update(RpcRequest request, Activity? activity = null) { DistributedContextPropagator.Inject(activity, request.Metadata, static (carrier, key, value) => ((IDictionary)carrier!)[key] = value); } - public void Update(Activity? activity, CloudEvent cloudEvent) + public void Update(CloudEvent cloudEvent, Activity? activity = null) { DistributedContextPropagator.Inject(activity, cloudEvent.Metadata, static (carrier, key, value) => ((IDictionary)carrier!)[key] = value); } @@ -45,6 +45,10 @@ public async ValueTask SendRequestAsync(IAgentBase agent, RpcRequest request, Ca { await worker.SendRequestAsync(agent, request, cancellationToken).ConfigureAwait(false); } + public async ValueTask SendMessageAsync(Message message, CancellationToken cancellationToken = default) + { + await worker.SendMessageAsync(message, cancellationToken).ConfigureAwait(false); + } public async ValueTask PublishEventAsync(CloudEvent @event, CancellationToken cancellationToken = default) { await worker.PublishEventAsync(@event, cancellationToken).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.AutoGen/Agents/Agents/AIAgent/InferenceAgent.cs b/dotnet/src/Microsoft.AutoGen/Agents/Agents/AIAgent/InferenceAgent.cs index a0383a3c219d..bf68467e3fa7 100644 --- a/dotnet/src/Microsoft.AutoGen/Agents/Agents/AIAgent/InferenceAgent.cs +++ b/dotnet/src/Microsoft.AutoGen/Agents/Agents/AIAgent/InferenceAgent.cs @@ -5,16 +5,14 @@ using Microsoft.AutoGen.Abstractions; using Microsoft.Extensions.AI; namespace Microsoft.AutoGen.Agents; -public abstract class InferenceAgent : AgentBase where T : IMessage, new() +public abstract class InferenceAgent( + IAgentRuntime context, + EventTypes typeRegistry, + IChatClient client) + : AgentBase(context, typeRegistry) + where T : IMessage, new() { - protected IChatClient ChatClient { get; } - public InferenceAgent( - IAgentRuntime context, - EventTypes typeRegistry, IChatClient client - ) : base(context, typeRegistry) - { - ChatClient = client; - } + protected IChatClient ChatClient { get; } = client; private Task CompleteAsync( IList chatMessages, diff --git a/dotnet/src/Microsoft.AutoGen/Agents/App.cs b/dotnet/src/Microsoft.AutoGen/Agents/App.cs index fc36d3367795..8a233bcd4898 100644 --- a/dotnet/src/Microsoft.AutoGen/Agents/App.cs +++ b/dotnet/src/Microsoft.AutoGen/Agents/App.cs @@ -12,6 +12,7 @@ public static class AgentsApp { // need a variable to store the runtime instance public static WebApplication? Host { get; private set; } + [MemberNotNull(nameof(Host))] public static async ValueTask StartAsync(WebApplicationBuilder? builder = null, AgentTypes? agentTypes = null, bool local = false) { @@ -58,7 +59,7 @@ public static async ValueTask ShutdownAsync() await Host.StopAsync(); } - private static AgentApplicationBuilder AddAgents(this AgentApplicationBuilder builder, AgentTypes? agentTypes) + private static IHostApplicationBuilder AddAgents(this IHostApplicationBuilder builder, AgentTypes? agentTypes) { agentTypes ??= AgentTypes.GetAgentTypesFromAssembly() ?? throw new InvalidOperationException("No agent types found in the assembly"); diff --git a/dotnet/src/Microsoft.AutoGen/Agents/Microsoft.AutoGen.Agents.csproj b/dotnet/src/Microsoft.AutoGen/Agents/Microsoft.AutoGen.Agents.csproj index 3bc2b3acb012..aa79cf9665ae 100644 --- a/dotnet/src/Microsoft.AutoGen/Agents/Microsoft.AutoGen.Agents.csproj +++ b/dotnet/src/Microsoft.AutoGen/Agents/Microsoft.AutoGen.Agents.csproj @@ -4,16 +4,14 @@ net8.0 enable enable - Microsoft.AutoGen.Agents - https://github.com/microsoft/autogen - Microsoft - Micrososft AutoGen Agents SDK - ai-agents;event-driven-agents + + + - + diff --git a/dotnet/src/Microsoft.AutoGen/Agents/Services/AgentWorker.cs b/dotnet/src/Microsoft.AutoGen/Agents/Services/AgentWorker.cs index 490051490315..f9a5050534c8 100644 --- a/dotnet/src/Microsoft.AutoGen/Agents/Services/AgentWorker.cs +++ b/dotnet/src/Microsoft.AutoGen/Agents/Services/AgentWorker.cs @@ -24,6 +24,8 @@ public class AgentWorker : private readonly CancellationTokenSource _shutdownCts; private readonly IServiceProvider _serviceProvider; private readonly IEnumerable> _configuredAgentTypes; + private readonly ConcurrentDictionary _subscriptionsByAgentType = new(); + private readonly ConcurrentDictionary> _subscriptionsByTopic = new(); private readonly DistributedContextPropagator _distributedContextPropagator; private readonly CancellationTokenSource _shutdownCancellationToken = new(); private Task? _mailboxTask; @@ -47,7 +49,7 @@ public async ValueTask PublishEventAsync(CloudEvent cloudEvent, CancellationToke { foreach (var (typeName, _) in _agentTypes) { - if (typeName == "Client") { continue; } + if (typeName == nameof(Client)) { continue; } var agent = GetOrActivateAgent(new AgentId(typeName, cloudEvent.Source)); agent.ReceiveMessage(new Message { CloudEvent = cloudEvent }); } @@ -63,6 +65,10 @@ public ValueTask SendResponseAsync(RpcResponse response, CancellationToken cance { return _mailbox.Writer.WriteAsync(new Message { Response = response }, cancellationToken); } + public ValueTask SendMessageAsync(Message message, CancellationToken cancellationToken = default) + { + return _mailbox.Writer.WriteAsync(message, cancellationToken); + } public ValueTask StoreAsync(AgentState value, CancellationToken cancellationToken = default) { var agentId = value.AgentId ?? throw new InvalidOperationException("AgentId is required when saving AgentState."); @@ -92,7 +98,7 @@ public async Task RunMessagePump() if (message == null) { continue; } switch (message) { - case Message msg: + case Message msg when msg.CloudEvent != null: var item = msg.CloudEvent; @@ -102,6 +108,13 @@ public async Task RunMessagePump() agentToInvoke.ReceiveMessage(msg); } break; + case Message msg when msg.AddSubscriptionRequest != null: + await AddSubscriptionRequestAsync(msg.AddSubscriptionRequest).ConfigureAwait(true); + break; + case Message msg when msg.AddSubscriptionResponse != null: + break; + case Message msg when msg.RegisterAgentTypeResponse != null: + break; default: throw new InvalidOperationException($"Unexpected message '{message}'."); } @@ -115,6 +128,23 @@ public async Task RunMessagePump() } } } + private async ValueTask AddSubscriptionRequestAsync(AddSubscriptionRequest subscription) + { + var topic = subscription.Subscription.TypeSubscription.TopicType; + var agentType = subscription.Subscription.TypeSubscription.AgentType; + _subscriptionsByAgentType[agentType] = subscription.Subscription; + _subscriptionsByTopic.GetOrAdd(topic, _ => []).Add(agentType); + Message response = new() + { + AddSubscriptionResponse = new() + { + RequestId = subscription.RequestId, + Error = "", + Success = true + } + }; + await _mailbox.Writer.WriteAsync(response).ConfigureAwait(false); + } public async Task StartAsync(CancellationToken cancellationToken) { diff --git a/dotnet/src/Microsoft.AutoGen/Agents/Services/AgentWorkerHostingExtensions.cs b/dotnet/src/Microsoft.AutoGen/Agents/Services/AgentWorkerHostingExtensions.cs index 8215f2032760..3736fc76cb61 100644 --- a/dotnet/src/Microsoft.AutoGen/Agents/Services/AgentWorkerHostingExtensions.cs +++ b/dotnet/src/Microsoft.AutoGen/Agents/Services/AgentWorkerHostingExtensions.cs @@ -3,8 +3,6 @@ using System.Diagnostics; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; @@ -13,25 +11,9 @@ namespace Microsoft.AutoGen.Agents; public static class AgentWorkerHostingExtensions { - public static WebApplicationBuilder AddAgentService(this WebApplicationBuilder builder, bool local = false, bool useGrpc = true) + public static IHostApplicationBuilder AddAgentService(this IHostApplicationBuilder builder, bool local = false, bool useGrpc = true) { - if (local) - { - //TODO: make configuration more flexible - builder.WebHost.ConfigureKestrel(serverOptions => - { - serverOptions.ListenLocalhost(5001, listenOptions => - { - listenOptions.Protocols = HttpProtocols.Http2; - listenOptions.UseHttps(); - }); - }); - builder.AddOrleans(local); - } - else - { - builder.AddOrleans(); - } + builder.AddOrleans(local); builder.Services.TryAddSingleton(DistributedContextPropagator.Current); @@ -45,10 +27,11 @@ public static WebApplicationBuilder AddAgentService(this WebApplicationBuilder b return builder; } - public static WebApplicationBuilder AddLocalAgentService(this WebApplicationBuilder builder, bool useGrpc = true) + public static IHostApplicationBuilder AddLocalAgentService(this IHostApplicationBuilder builder, bool useGrpc = true) { return builder.AddAgentService(local: true, useGrpc); } + public static WebApplication MapAgentService(this WebApplication app, bool local = false, bool useGrpc = true) { if (useGrpc) { app.MapGrpcService(); } diff --git a/dotnet/src/Microsoft.AutoGen/Agents/Services/Grpc/GrpcAgentWorker.cs b/dotnet/src/Microsoft.AutoGen/Agents/Services/Grpc/GrpcAgentWorker.cs index 431a5629c142..48f07573430d 100644 --- a/dotnet/src/Microsoft.AutoGen/Agents/Services/Grpc/GrpcAgentWorker.cs +++ b/dotnet/src/Microsoft.AutoGen/Agents/Services/Grpc/GrpcAgentWorker.cs @@ -85,6 +85,13 @@ private async Task RunReadPump() } break; + case Message.MessageOneofCase.AddSubscriptionResponse: + if (!message.AddSubscriptionResponse.Success) + { + throw new InvalidOperationException($"Failed to add subscription: '{message.AddSubscriptionResponse.Error}'."); + } + break; + case Message.MessageOneofCase.CloudEvent: // HACK: Send the message to an instance of each agent type @@ -153,6 +160,13 @@ private async Task RunWritePump() item.WriteCompletionSource?.TrySetCanceled(); break; } + catch (RpcException ex) when (ex.StatusCode == StatusCode.Unavailable) + { + // we could not connect to the endpoint - most likely we have the wrong port or failed ssl + // we need to let the user know what port we tried to connect to and then do backoff and retry + _logger.LogError(ex, "Error connecting to GRPC endpoint {Endpoint}.", channel.ToString()); + break; + } catch (Exception ex) when (!_shutdownCts.IsCancellationRequested) { item.WriteCompletionSource?.TrySetException(ex); @@ -230,6 +244,11 @@ await WriteChannelAsync(new Message await WriteChannelAsync(new Message { Request = request }, cancellationToken).ConfigureAwait(false); } // new is intentional + public new async ValueTask SendMessageAsync(Message message, CancellationToken cancellationToken = default) + { + await WriteChannelAsync(message, cancellationToken).ConfigureAwait(false); + } + // new is intentional public new async ValueTask PublishEventAsync(CloudEvent @event, CancellationToken cancellationToken = default) { await WriteChannelAsync(new Message { CloudEvent = @event }, cancellationToken).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.AutoGen/Agents/Services/Grpc/GrpcAgentWorkerHostBuilderExtension.cs b/dotnet/src/Microsoft.AutoGen/Agents/Services/Grpc/GrpcAgentWorkerHostBuilderExtension.cs index 670411b33677..6757428302f4 100644 --- a/dotnet/src/Microsoft.AutoGen/Agents/Services/Grpc/GrpcAgentWorkerHostBuilderExtension.cs +++ b/dotnet/src/Microsoft.AutoGen/Agents/Services/Grpc/GrpcAgentWorkerHostBuilderExtension.cs @@ -11,12 +11,12 @@ namespace Microsoft.AutoGen.Agents; public static class GrpcAgentWorkerHostBuilderExtensions { - private const string _defaultAgentServiceAddress = "https://localhost:5001"; - public static IHostApplicationBuilder AddGrpcAgentWorker(this IHostApplicationBuilder builder, string agentServiceAddress = _defaultAgentServiceAddress) + private const string _defaultAgentServiceAddress = "https://localhost:53071"; + public static IHostApplicationBuilder AddGrpcAgentWorker(this IHostApplicationBuilder builder, string? agentServiceAddress = null) { builder.Services.AddGrpcClient(options => { - options.Address = new Uri(agentServiceAddress); + options.Address = new Uri(agentServiceAddress ?? builder.Configuration["AGENT_HOST"] ?? _defaultAgentServiceAddress); options.ChannelOptionsActions.Add(channelOptions => { diff --git a/dotnet/src/Microsoft.AutoGen/Agents/Services/Grpc/GrpcGateway.cs b/dotnet/src/Microsoft.AutoGen/Agents/Services/Grpc/GrpcGateway.cs index 89e9c55c4648..ab24a0e15fe5 100644 --- a/dotnet/src/Microsoft.AutoGen/Agents/Services/Grpc/GrpcGateway.cs +++ b/dotnet/src/Microsoft.AutoGen/Agents/Services/Grpc/GrpcGateway.cs @@ -16,10 +16,13 @@ public sealed class GrpcGateway : BackgroundService, IGateway private readonly IClusterClient _clusterClient; private readonly ConcurrentDictionary _agentState = new(); private readonly IRegistryGrain _gatewayRegistry; + private readonly ISubscriptionsGrain _subscriptions; private readonly IGateway _reference; // The agents supported by each worker process. private readonly ConcurrentDictionary> _supportedAgentTypes = []; public readonly ConcurrentDictionary _workers = new(); + private readonly ConcurrentDictionary _subscriptionsByAgentType = new(); + private readonly ConcurrentDictionary> _subscriptionsByTopic = new(); // The mapping from agent id to worker process. private readonly ConcurrentDictionary<(string Type, string Key), GrpcWorkerConnection> _agentDirectory = new(); @@ -33,6 +36,7 @@ public GrpcGateway(IClusterClient clusterClient, ILogger logger) _clusterClient = clusterClient; _reference = clusterClient.CreateObjectReference(this); _gatewayRegistry = clusterClient.GetGrain(0); + _subscriptions = clusterClient.GetGrain(0); } public async ValueTask BroadcastEvent(CloudEvent evt) { @@ -102,16 +106,70 @@ internal async Task OnReceivedMessageAsync(GrpcWorkerConnection connection, Mess case Message.MessageOneofCase.RegisterAgentTypeRequest: await RegisterAgentTypeAsync(connection, message.RegisterAgentTypeRequest); break; + case Message.MessageOneofCase.AddSubscriptionRequest: + await AddSubscriptionAsync(connection, message.AddSubscriptionRequest); + break; default: - throw new InvalidOperationException($"Unknown message type for message '{message}'."); + // if it wasn't recognized return bad request + await RespondBadRequestAsync(connection, $"Unknown message type for message '{message}'."); + break; + }; + } + private async ValueTask RespondBadRequestAsync(GrpcWorkerConnection connection, string error) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, error)); + } + private async ValueTask AddSubscriptionAsync(GrpcWorkerConnection connection, AddSubscriptionRequest request) + { + var topic = request.Subscription.TypeSubscription.TopicType; + var agentType = request.Subscription.TypeSubscription.AgentType; + _subscriptionsByAgentType[agentType] = request.Subscription; + _subscriptionsByTopic.GetOrAdd(topic, _ => []).Add(agentType); + await _subscriptions.Subscribe(topic, agentType); + //var response = new AddSubscriptionResponse { RequestId = request.RequestId, Error = "", Success = true }; + Message response = new() + { + AddSubscriptionResponse = new() + { + RequestId = request.RequestId, + Error = "", + Success = true + } }; + await connection.ResponseStream.WriteAsync(response).ConfigureAwait(false); } private async ValueTask RegisterAgentTypeAsync(GrpcWorkerConnection connection, RegisterAgentTypeRequest msg) { connection.AddSupportedType(msg.Type); _supportedAgentTypes.GetOrAdd(msg.Type, _ => []).Add(connection); - await _gatewayRegistry.RegisterAgentType(msg.Type, _reference); + await _gatewayRegistry.RegisterAgentType(msg.Type, _reference).ConfigureAwait(true); + Message response = new() + { + RegisterAgentTypeResponse = new() + { + RequestId = msg.RequestId, + Error = "", + Success = true + } + }; + // add a default subscription for the agent type + //TODO: we should consider having constraints on the namespace or at least migrate all our examples to use well typed namesspaces like com.microsoft.autogen/hello/HelloAgents etc + var subscriptionRequest = new AddSubscriptionRequest + { + RequestId = Guid.NewGuid().ToString(), + Subscription = new Subscription + { + TypeSubscription = new TypeSubscription + { + AgentType = msg.Type, + TopicType = msg.Type + } + } + }; + await AddSubscriptionAsync(connection, subscriptionRequest).ConfigureAwait(true); + + await connection.ResponseStream.WriteAsync(response).ConfigureAwait(false); } private async ValueTask DispatchEventAsync(CloudEvent evt) { diff --git a/dotnet/src/Microsoft.AutoGen/Agents/Services/Host.cs b/dotnet/src/Microsoft.AutoGen/Agents/Services/Host.cs index 5b725af0c9a9..464536d54b21 100644 --- a/dotnet/src/Microsoft.AutoGen/Agents/Services/Host.cs +++ b/dotnet/src/Microsoft.AutoGen/Agents/Services/Host.cs @@ -14,11 +14,11 @@ public static async Task StartAsync(bool local = false, bool use builder.AddServiceDefaults(); if (local) { - builder.AddLocalAgentService(useGrpc); + builder.AddLocalAgentService(useGrpc: useGrpc); } else { - builder.AddAgentService(useGrpc); + builder.AddAgentService(useGrpc: useGrpc); } var app = builder.Build(); app.MapAgentService(local, useGrpc); diff --git a/dotnet/src/Microsoft.AutoGen/Agents/Services/HostBuilderExtensions.cs b/dotnet/src/Microsoft.AutoGen/Agents/Services/HostBuilderExtensions.cs index 8ded5cb03d2d..f21096ccfbdb 100644 --- a/dotnet/src/Microsoft.AutoGen/Agents/Services/HostBuilderExtensions.cs +++ b/dotnet/src/Microsoft.AutoGen/Agents/Services/HostBuilderExtensions.cs @@ -15,9 +15,25 @@ namespace Microsoft.AutoGen.Agents; public static class HostBuilderExtensions { - private const string _defaultAgentServiceAddress = "https://localhost:5001"; - public static AgentApplicationBuilder AddAgentWorker(this IHostApplicationBuilder builder, string agentServiceAddress = _defaultAgentServiceAddress, bool local = false) + private const string _defaultAgentServiceAddress = "https://localhost:53071"; + + public static IHostApplicationBuilder AddAgent< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TAgent>(this IHostApplicationBuilder builder, string typeName) where TAgent : AgentBase + { + builder.Services.AddKeyedSingleton("AgentTypes", (sp, key) => Tuple.Create(typeName, typeof(TAgent))); + + return builder; + } + + public static IHostApplicationBuilder AddAgent(this IHostApplicationBuilder builder, string typeName, Type agentType) { + builder.Services.AddKeyedSingleton("AgentTypes", (sp, key) => Tuple.Create(typeName, agentType)); + return builder; + } + + public static IHostApplicationBuilder AddAgentWorker(this IHostApplicationBuilder builder, string? agentServiceAddress = null, bool local = false) + { + agentServiceAddress ??= builder.Configuration["AGENT_HOST"] ?? _defaultAgentServiceAddress; builder.Services.TryAddSingleton(DistributedContextPropagator.Current); // if !local, then add the gRPC client @@ -98,7 +114,9 @@ public static AgentApplicationBuilder AddAgentWorker(this IHostApplicationBuilde return new EventTypes(typeRegistry, types, eventsMap); }); builder.Services.AddSingleton(); - return new AgentApplicationBuilder(builder); + builder.Services.AddSingleton(new AgentApplicationBuilder(builder)); + + return builder; } private static MessageDescriptor? GetMessageDescriptor(Type type) diff --git a/dotnet/src/Microsoft.AutoGen/Abstractions/IGateway.cs b/dotnet/src/Microsoft.AutoGen/Agents/Services/IGateway.cs similarity index 84% rename from dotnet/src/Microsoft.AutoGen/Abstractions/IGateway.cs rename to dotnet/src/Microsoft.AutoGen/Agents/Services/IGateway.cs index 79b7b63e7235..539ec3eca435 100644 --- a/dotnet/src/Microsoft.AutoGen/Abstractions/IGateway.cs +++ b/dotnet/src/Microsoft.AutoGen/Agents/Services/IGateway.cs @@ -1,7 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // IGateway.cs +using Microsoft.AutoGen.Abstractions; -namespace Microsoft.AutoGen.Abstractions; +namespace Microsoft.AutoGen.Agents; public interface IGateway : IGrainObserver { diff --git a/dotnet/src/Microsoft.AutoGen/Agents/Services/Orleans/AgentStateGrain.cs b/dotnet/src/Microsoft.AutoGen/Agents/Services/Orleans/AgentStateGrain.cs index 50d8c3ad4542..9905f6aebac6 100644 --- a/dotnet/src/Microsoft.AutoGen/Agents/Services/Orleans/AgentStateGrain.cs +++ b/dotnet/src/Microsoft.AutoGen/Agents/Services/Orleans/AgentStateGrain.cs @@ -7,7 +7,8 @@ namespace Microsoft.AutoGen.Agents; internal sealed class AgentStateGrain([PersistentState("state", "AgentStateStore")] IPersistentState state) : Grain, IAgentState { - public async ValueTask WriteStateAsync(AgentState newState, string eTag) + /// + public async ValueTask WriteStateAsync(AgentState newState, string eTag, CancellationToken cancellationToken = default) { // etags for optimistic concurrency control // if the Etag is null, its a new state @@ -27,7 +28,8 @@ public async ValueTask WriteStateAsync(AgentState newState, string eTag) return state.Etag; } - public ValueTask ReadStateAsync() + /// + public ValueTask ReadStateAsync(CancellationToken cancellationToken = default) { return ValueTask.FromResult(state.State); } diff --git a/dotnet/src/Microsoft.AutoGen/Agents/Services/Orleans/ISubscriptionsGrain.cs b/dotnet/src/Microsoft.AutoGen/Agents/Services/Orleans/ISubscriptionsGrain.cs new file mode 100644 index 000000000000..302df9ebff98 --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/Agents/Services/Orleans/ISubscriptionsGrain.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ISubscriptionsGrain.cs + +namespace Microsoft.AutoGen.Agents; +public interface ISubscriptionsGrain : IGrainWithIntegerKey +{ + ValueTask Subscribe(string agentType, string topic); + ValueTask Unsubscribe(string agentType, string topic); + ValueTask>> GetSubscriptions(string agentType); +} diff --git a/dotnet/src/Microsoft.AutoGen/Agents/Services/Orleans/OrleansRuntimeHostingExtenions.cs b/dotnet/src/Microsoft.AutoGen/Agents/Services/Orleans/OrleansRuntimeHostingExtenions.cs index cd59bcefc385..374e49f7a500 100644 --- a/dotnet/src/Microsoft.AutoGen/Agents/Services/Orleans/OrleansRuntimeHostingExtenions.cs +++ b/dotnet/src/Microsoft.AutoGen/Agents/Services/Orleans/OrleansRuntimeHostingExtenions.cs @@ -15,11 +15,17 @@ public static class OrleansRuntimeHostingExtenions { public static WebApplicationBuilder AddOrleans(this WebApplicationBuilder builder, bool local = false) { + return builder.AddOrleans(local); + } + public static IHostApplicationBuilder AddOrleans(this IHostApplicationBuilder builder, bool local = false) + { builder.Services.AddSerializer(serializer => serializer.AddProtobufSerializer()); + builder.Services.AddSingleton(); + // Ensure Orleans is added before the hosted service to guarantee that it starts first. //TODO: make all of this configurable - builder.Host.UseOrleans(siloBuilder => + builder.UseOrleans((siloBuilder) => { // Development mode or local mode uses in-memory storage and streams if (builder.Environment.IsDevelopment() || local) @@ -51,16 +57,16 @@ public static WebApplicationBuilder AddOrleans(this WebApplicationBuilder builde options.SystemResponseTimeout = TimeSpan.FromMinutes(3); }); siloBuilder.Configure(options => - { - options.ResponseTimeout = TimeSpan.FromMinutes(3); - }); + { + options.ResponseTimeout = TimeSpan.FromMinutes(3); + }); siloBuilder.UseCosmosClustering(o => - { - o.ConfigureCosmosClient(cosmosDbconnectionString); - o.ContainerName = "AutoGen"; - o.DatabaseName = "clustering"; - o.IsResourceCreationEnabled = true; - }); + { + o.ConfigureCosmosClient(cosmosDbconnectionString); + o.ContainerName = "AutoGen"; + o.DatabaseName = "clustering"; + o.IsResourceCreationEnabled = true; + }); siloBuilder.UseCosmosReminderService(o => { @@ -84,7 +90,7 @@ public static WebApplicationBuilder AddOrleans(this WebApplicationBuilder builde .AddMemoryGrainStorage("PubSubStore"); } }); - builder.Services.AddSingleton(); + return builder; } } diff --git a/dotnet/src/Microsoft.AutoGen/Agents/Services/Orleans/RegistryGrain.cs b/dotnet/src/Microsoft.AutoGen/Agents/Services/Orleans/RegistryGrain.cs index c5114e3e7423..cb7523126436 100644 --- a/dotnet/src/Microsoft.AutoGen/Agents/Services/Orleans/RegistryGrain.cs +++ b/dotnet/src/Microsoft.AutoGen/Agents/Services/Orleans/RegistryGrain.cs @@ -5,7 +5,7 @@ namespace Microsoft.AutoGen.Agents; -public sealed class RegistryGrain : Grain, IRegistryGrain +internal sealed class RegistryGrain : Grain, IRegistryGrain { // TODO: use persistent state for some of these or (better) extend Orleans to implement some of this natively. private readonly Dictionary _workerStates = new(); diff --git a/dotnet/src/Microsoft.AutoGen/Agents/Services/Orleans/SubscriptionsGrain.cs b/dotnet/src/Microsoft.AutoGen/Agents/Services/Orleans/SubscriptionsGrain.cs new file mode 100644 index 000000000000..682073f0b97c --- /dev/null +++ b/dotnet/src/Microsoft.AutoGen/Agents/Services/Orleans/SubscriptionsGrain.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// SubscriptionsGrain.cs + +namespace Microsoft.AutoGen.Agents; + +internal sealed class SubscriptionsGrain([PersistentState("state", "PubSubStore")] IPersistentState state) : Grain, ISubscriptionsGrain +{ + private readonly Dictionary> _subscriptions = new(); + public ValueTask>> GetSubscriptions(string? agentType = null) + { + //if agentType is null, return all subscriptions else filter on agentType + if (agentType != null) + { + return new ValueTask>>(_subscriptions.Where(x => x.Value.Contains(agentType)).ToDictionary(x => x.Key, x => x.Value)); + } + return new ValueTask>>(_subscriptions); + } + public ValueTask Subscribe(string agentType, string topic) + { + if (!_subscriptions.TryGetValue(topic, out var subscriptions)) + { + subscriptions = _subscriptions[topic] = []; + } + if (!subscriptions.Contains(agentType)) + { + subscriptions.Add(agentType); + } + _subscriptions[topic] = subscriptions; + state.State.Subscriptions = _subscriptions; + state.WriteStateAsync(); + + return ValueTask.CompletedTask; + } + public ValueTask Unsubscribe(string agentType, string topic) + { + if (!_subscriptions.TryGetValue(topic, out var subscriptions)) + { + subscriptions = _subscriptions[topic] = []; + } + if (!subscriptions.Contains(agentType)) + { + subscriptions.Remove(agentType); + } + _subscriptions[topic] = subscriptions; + state.State.Subscriptions = _subscriptions; + state.WriteStateAsync(); + return ValueTask.CompletedTask; + } +} +public sealed class SubscriptionsState +{ + public Dictionary> Subscriptions { get; set; } = new(); +} diff --git a/dotnet/src/Microsoft.AutoGen/Extensions/ServiceDefaults/Extensions.cs b/dotnet/src/Microsoft.AutoGen/Extensions/Aspire/AspireHostingExtensions.cs similarity index 98% rename from dotnet/src/Microsoft.AutoGen/Extensions/ServiceDefaults/Extensions.cs rename to dotnet/src/Microsoft.AutoGen/Extensions/Aspire/AspireHostingExtensions.cs index a4eccacb7fd8..0e6781d740eb 100644 --- a/dotnet/src/Microsoft.AutoGen/Extensions/ServiceDefaults/Extensions.cs +++ b/dotnet/src/Microsoft.AutoGen/Extensions/Aspire/AspireHostingExtensions.cs @@ -1,5 +1,5 @@ // Copyright (c) Microsoft Corporation. All rights reserved. -// Extensions.cs +// AspireHostingExtensions.cs using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; @@ -15,7 +15,7 @@ namespace Microsoft.Extensions.Hosting; // Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. // This project should be referenced by each service project in your solution. // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults -public static class Extensions +public static class AspireHostingExtensions { public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) { diff --git a/dotnet/src/Microsoft.AutoGen/Extensions/ServiceDefaults/Microsoft.AutoGen.ServiceDefaults.csproj b/dotnet/src/Microsoft.AutoGen/Extensions/Aspire/Microsoft.AutoGen.Extensions.Aspire.csproj similarity index 93% rename from dotnet/src/Microsoft.AutoGen/Extensions/ServiceDefaults/Microsoft.AutoGen.ServiceDefaults.csproj rename to dotnet/src/Microsoft.AutoGen/Extensions/Aspire/Microsoft.AutoGen.Extensions.Aspire.csproj index cf2446f93349..0cab61bc27b6 100644 --- a/dotnet/src/Microsoft.AutoGen/Extensions/ServiceDefaults/Microsoft.AutoGen.ServiceDefaults.csproj +++ b/dotnet/src/Microsoft.AutoGen/Extensions/Aspire/Microsoft.AutoGen.Extensions.Aspire.csproj @@ -5,6 +5,9 @@ enable true + + + diff --git a/dotnet/src/Microsoft.AutoGen/Extensions/AIModelClientHostingExtensions/AIModelClientHostingExtensions.cs b/dotnet/src/Microsoft.AutoGen/Extensions/MEAI/MEAIHostingExtensions.cs similarity index 94% rename from dotnet/src/Microsoft.AutoGen/Extensions/AIModelClientHostingExtensions/AIModelClientHostingExtensions.cs rename to dotnet/src/Microsoft.AutoGen/Extensions/MEAI/MEAIHostingExtensions.cs index c3c9c197392d..d39f358f8cbe 100644 --- a/dotnet/src/Microsoft.AutoGen/Extensions/AIModelClientHostingExtensions/AIModelClientHostingExtensions.cs +++ b/dotnet/src/Microsoft.AutoGen/Extensions/MEAI/MEAIHostingExtensions.cs @@ -1,11 +1,11 @@ // Copyright (c) Microsoft Corporation. All rights reserved. -// AIModelClientHostingExtensions.cs +// MEAIHostingExtensions.cs using Microsoft.Extensions.AI; namespace Microsoft.Extensions.Hosting; -public static class AIModelClient +public static class MEAIHostingExtensions { public static IHostApplicationBuilder AddChatCompletionService(this IHostApplicationBuilder builder, string serviceName) { diff --git a/dotnet/src/Microsoft.AutoGen/Extensions/AIModelClientHostingExtensions/AIModelClientHostingExtensions.csproj b/dotnet/src/Microsoft.AutoGen/Extensions/MEAI/Microsoft.AutoGen.Extensions.MEAI.csproj similarity index 87% rename from dotnet/src/Microsoft.AutoGen/Extensions/AIModelClientHostingExtensions/AIModelClientHostingExtensions.csproj rename to dotnet/src/Microsoft.AutoGen/Extensions/MEAI/Microsoft.AutoGen.Extensions.MEAI.csproj index 2358351deb6c..b8233a8e6c50 100644 --- a/dotnet/src/Microsoft.AutoGen/Extensions/AIModelClientHostingExtensions/AIModelClientHostingExtensions.csproj +++ b/dotnet/src/Microsoft.AutoGen/Extensions/MEAI/Microsoft.AutoGen.Extensions.MEAI.csproj @@ -4,6 +4,9 @@ enable enable + + + @@ -14,6 +17,6 @@ - + diff --git a/dotnet/src/Microsoft.AutoGen/Extensions/AIModelClientHostingExtensions/Options/AIClientOptions.cs b/dotnet/src/Microsoft.AutoGen/Extensions/MEAI/Options/AIClientOptions.cs similarity index 100% rename from dotnet/src/Microsoft.AutoGen/Extensions/AIModelClientHostingExtensions/Options/AIClientOptions.cs rename to dotnet/src/Microsoft.AutoGen/Extensions/MEAI/Options/AIClientOptions.cs diff --git a/dotnet/src/Microsoft.AutoGen/Extensions/AIModelClientHostingExtensions/ServiceCollectionChatCompletionExtensions.cs b/dotnet/src/Microsoft.AutoGen/Extensions/MEAI/ServiceCollectionChatCompletionExtensions.cs similarity index 100% rename from dotnet/src/Microsoft.AutoGen/Extensions/AIModelClientHostingExtensions/ServiceCollectionChatCompletionExtensions.cs rename to dotnet/src/Microsoft.AutoGen/Extensions/MEAI/ServiceCollectionChatCompletionExtensions.cs diff --git a/dotnet/src/Microsoft.AutoGen/Extensions/SemanticKernel/Microsoft.AutoGen.Extensions.SemanticKernel.csproj b/dotnet/src/Microsoft.AutoGen/Extensions/SemanticKernel/Microsoft.AutoGen.Extensions.SemanticKernel.csproj index fb47750fd44d..c4ac5536e70c 100644 --- a/dotnet/src/Microsoft.AutoGen/Extensions/SemanticKernel/Microsoft.AutoGen.Extensions.SemanticKernel.csproj +++ b/dotnet/src/Microsoft.AutoGen/Extensions/SemanticKernel/Microsoft.AutoGen.Extensions.SemanticKernel.csproj @@ -1,22 +1,23 @@ - - - - - - net8.0 enable enable - - - + + + + + + + + + + diff --git a/dotnet/test/AutoGen.AotCompatibility.Tests/AutoGen.AotCompatibility.Tests.csproj b/dotnet/test/AutoGen.AotCompatibility.Tests/AutoGen.AotCompatibility.Tests.csproj index aec9660bb922..379bca541012 100644 --- a/dotnet/test/AutoGen.AotCompatibility.Tests/AutoGen.AotCompatibility.Tests.csproj +++ b/dotnet/test/AutoGen.AotCompatibility.Tests/AutoGen.AotCompatibility.Tests.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + net8.0 enable enable true diff --git a/dotnet/test/AutoGen.OpenAI.Tests/RolePlayToolCallOrchestratorTests.cs b/dotnet/test/AutoGen.OpenAI.Tests/RolePlayToolCallOrchestratorTests.cs new file mode 100644 index 000000000000..807bf41e9479 --- /dev/null +++ b/dotnet/test/AutoGen.OpenAI.Tests/RolePlayToolCallOrchestratorTests.cs @@ -0,0 +1,269 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// RolePlayToolCallOrchestratorTests.cs + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AutoGen.OpenAI.Orchestrator; +using AutoGen.Tests; +using Azure.AI.OpenAI; +using FluentAssertions; +using Moq; +using OpenAI; +using OpenAI.Chat; +using Xunit; + +namespace AutoGen.OpenAI.Tests; + +public class RolePlayToolCallOrchestratorTests +{ + [Fact] + public async Task ItReturnNullWhenNoCandidateIsAvailableAsync() + { + var chatClient = Mock.Of(); + var orchestrator = new RolePlayToolCallOrchestrator(chatClient); + var context = new OrchestrationContext + { + Candidates = [], + ChatHistory = [], + }; + + var speaker = await orchestrator.GetNextSpeakerAsync(context); + speaker.Should().BeNull(); + } + + [Fact] + public async Task ItReturnCandidateWhenOnlyOneCandidateIsAvailableAsync() + { + var chatClient = Mock.Of(); + var alice = new EchoAgent("Alice"); + var orchestrator = new RolePlayToolCallOrchestrator(chatClient); + var context = new OrchestrationContext + { + Candidates = [alice], + ChatHistory = [], + }; + + var speaker = await orchestrator.GetNextSpeakerAsync(context); + speaker.Should().Be(alice); + } + + [Fact] + public async Task ItSelectNextSpeakerFromWorkflowIfProvided() + { + var workflow = new Graph(); + var alice = new EchoAgent("Alice"); + var bob = new EchoAgent("Bob"); + var charlie = new EchoAgent("Charlie"); + workflow.AddTransition(Transition.Create(alice, bob)); + workflow.AddTransition(Transition.Create(bob, charlie)); + workflow.AddTransition(Transition.Create(charlie, alice)); + + var client = Mock.Of(); + var orchestrator = new RolePlayToolCallOrchestrator(client, workflow); + var context = new OrchestrationContext + { + Candidates = [alice, bob, charlie], + ChatHistory = + [ + new TextMessage(Role.User, "Hello, Bob", from: "Alice"), + ], + }; + + var speaker = await orchestrator.GetNextSpeakerAsync(context); + speaker.Should().Be(bob); + } + + [Fact] + public async Task ItReturnNullIfNoAvailableAgentFromWorkflowAsync() + { + var workflow = new Graph(); + var alice = new EchoAgent("Alice"); + var bob = new EchoAgent("Bob"); + workflow.AddTransition(Transition.Create(alice, bob)); + + var client = Mock.Of(); + var orchestrator = new RolePlayToolCallOrchestrator(client, workflow); + var context = new OrchestrationContext + { + Candidates = [alice, bob], + ChatHistory = + [ + new TextMessage(Role.User, "Hello, Alice", from: "Bob"), + ], + }; + + var speaker = await orchestrator.GetNextSpeakerAsync(context); + speaker.Should().BeNull(); + } + + [ApiKeyFact("AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_DEPLOY_NAME")] + public async Task GPT_3_5_CoderReviewerRunnerTestAsync() + { + var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new Exception("Please set AZURE_OPENAI_ENDPOINT environment variable."); + var key = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY") ?? throw new Exception("Please set AZURE_OPENAI_API_KEY environment variable."); + var deployName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOY_NAME") ?? throw new Exception("Please set AZURE_OPENAI_DEPLOY_NAME environment variable."); + var openaiClient = new AzureOpenAIClient(new Uri(endpoint), new System.ClientModel.ApiKeyCredential(key)); + var chatClient = openaiClient.GetChatClient(deployName); + + await BusinessWorkflowTest(chatClient); + await CoderReviewerRunnerTestAsync(chatClient); + } + + [ApiKeyFact("OPENAI_API_KEY")] + public async Task GPT_4o_CoderReviewerRunnerTestAsync() + { + var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set"); + var model = "gpt-4o"; + var openaiClient = new OpenAIClient(apiKey); + var chatClient = openaiClient.GetChatClient(model); + + await BusinessWorkflowTest(chatClient); + await CoderReviewerRunnerTestAsync(chatClient); + } + + [ApiKeyFact("OPENAI_API_KEY")] + public async Task GPT_4o_mini_CoderReviewerRunnerTestAsync() + { + var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set"); + var model = "gpt-4o-mini"; + var openaiClient = new OpenAIClient(apiKey); + var chatClient = openaiClient.GetChatClient(model); + + await BusinessWorkflowTest(chatClient); + await CoderReviewerRunnerTestAsync(chatClient); + } + + /// + /// This test is to mimic the conversation among coder, reviewer and runner. + /// The coder will write the code, the reviewer will review the code, and the runner will run the code. + /// + /// + /// + private async Task CoderReviewerRunnerTestAsync(ChatClient client) + { + var coder = new EchoAgent("Coder"); + var reviewer = new EchoAgent("Reviewer"); + var runner = new EchoAgent("Runner"); + var user = new EchoAgent("User"); + var initializeMessage = new List + { + new TextMessage(Role.User, "Hello, I am user, I will provide the coding task, please write the code first, then review and run it", from: "User"), + new TextMessage(Role.User, "Hello, I am coder, I will write the code", from: "Coder"), + new TextMessage(Role.User, "Hello, I am reviewer, I will review the code", from: "Reviewer"), + new TextMessage(Role.User, "Hello, I am runner, I will run the code", from: "Runner"), + new TextMessage(Role.User, "how to print 'hello world' using C#", from: user.Name), + }; + + var chatHistory = new List() + { + new TextMessage(Role.User, """ + ```csharp + Console.WriteLine("Hello World"); + ``` + """, from: coder.Name), + new TextMessage(Role.User, "The code looks good", from: reviewer.Name), + new TextMessage(Role.User, "The code runs successfully, the output is 'Hello World'", from: runner.Name), + }; + + var orchestrator = new RolePlayToolCallOrchestrator(client); + foreach (var message in chatHistory) + { + var context = new OrchestrationContext + { + Candidates = [coder, reviewer, runner, user], + ChatHistory = initializeMessage, + }; + + var speaker = await orchestrator.GetNextSpeakerAsync(context); + speaker!.Name.Should().Be(message.From); + initializeMessage.Add(message); + } + + // the last next speaker should be the user + var lastSpeaker = await orchestrator.GetNextSpeakerAsync(new OrchestrationContext + { + Candidates = [coder, reviewer, runner, user], + ChatHistory = initializeMessage, + }); + + lastSpeaker!.Name.Should().Be(user.Name); + } + + // test if the tool call orchestrator still run business workflow when the conversation is not in English + private async Task BusinessWorkflowTest(ChatClient client) + { + var ceo = new EchoAgent("乙方首席执行官"); + var pm = new EchoAgent("乙方项目经理"); + var dev = new EchoAgent("乙方开发人员"); + var user = new EchoAgent("甲方"); + var initializeMessage = new List + { + new TextMessage(Role.User, "你好,我是你们的甲方", from: user.Name), + new TextMessage(Role.User, "你好,我是乙方首席执行官,我将负责对接甲方和给项目经理及开发人员分配任务", from: ceo.Name), + new TextMessage(Role.User, "你好,我是乙方项目经理,我将负责项目的进度和质量", from: pm.Name), + new TextMessage(Role.User, "你好,我是乙方开发人员 我将负责项目的具体开发", from: dev.Name), + new TextMessage(Role.User, "开发一个淘宝,预算1W", from: user.Name), + }; + + var workflow = new Graph(); + workflow.AddTransition(Transition.Create(ceo, pm)); + workflow.AddTransition(Transition.Create(ceo, dev)); + workflow.AddTransition(Transition.Create(pm, ceo)); + workflow.AddTransition(Transition.Create(dev, ceo)); + workflow.AddTransition(Transition.Create(user, ceo)); + workflow.AddTransition(Transition.Create(ceo, user)); + + var chatHistory = new List() + { + new TextMessage(Role.User, """ + 项目经理,如何使用1W预算开发一个淘宝 + """, from: ceo.Name), + new TextMessage(Role.User, """ + 对于1万预算开发淘宝类网站,以下是关键建议: + 技术选择: + - 使用开源电商系统节省成本, 选择便宜但稳定的云服务器和域名,预算2000元/年 + - 核心功能优先 + - 人员安排: + - 找1位全栈开发,负责系统搭建(6000元) + - 1位兼职UI设计(2000元) + - 进度规划: + - 基础功能1个月完成,后续根据运营情况逐步优化。 + """, from: pm.Name), + new TextMessage(Role.User, "好的,开发人员,请根据项目经理的规划开始开发", from: ceo.Name), + new TextMessage(Role.User, """ + 好的,已开发完毕 + ```html + + ``` + """, from: dev.Name), + new TextMessage(Role.User, "好的,项目已完成,甲方请付款", from: ceo.Name), + }; + + var orchestrator = new RolePlayToolCallOrchestrator(client, workflow); + + foreach (var message in chatHistory) + { + var context = new OrchestrationContext + { + Candidates = [ceo, pm, dev, user], + ChatHistory = initializeMessage, + }; + + var speaker = await orchestrator.GetNextSpeakerAsync(context); + speaker!.Name.Should().Be(message.From); + initializeMessage.Add(message); + } + + // the last next speaker should be the user + var lastSpeaker = await orchestrator.GetNextSpeakerAsync(new OrchestrationContext + { + Candidates = [ceo, pm, dev, user], + ChatHistory = initializeMessage, + }); + + lastSpeaker!.Name.Should().Be(user.Name); + } +} diff --git a/dotnet/test/AutoGen.Tests/AutoGen.Tests.csproj b/dotnet/test/AutoGen.Tests/AutoGen.Tests.csproj index 248a9e29b00d..367d74619bb4 100644 --- a/dotnet/test/AutoGen.Tests/AutoGen.Tests.csproj +++ b/dotnet/test/AutoGen.Tests/AutoGen.Tests.csproj @@ -14,7 +14,7 @@ - + diff --git a/dotnet/test/AutoGen.Tests/ImageMessageTests.cs b/dotnet/test/AutoGen.Tests/ImageMessageTests.cs index e8a30c87012c..bb256a170f2a 100644 --- a/dotnet/test/AutoGen.Tests/ImageMessageTests.cs +++ b/dotnet/test/AutoGen.Tests/ImageMessageTests.cs @@ -35,4 +35,18 @@ public async Task ItCreateFromUrl() imageMessage.MimeType.Should().Be("image/png"); imageMessage.Data.Should().BeNull(); } + + [Fact] + public async Task ItCreateFromBase64Url() + { + var image = Path.Combine("testData", "images", "background.png"); + var binary = File.ReadAllBytes(image); + var base64 = Convert.ToBase64String(binary); + + var base64Url = $"data:image/png;base64,{base64}"; + var imageMessage = new ImageMessage(Role.User, base64Url); + + imageMessage.BuildDataUri().Should().Be(base64Url); + imageMessage.MimeType.Should().Be("image/png"); + } } diff --git a/dotnet/test/Microsoft.AutoGen.Agents.Tests/AgentBaseTests.cs b/dotnet/test/Microsoft.AutoGen.Agents.Tests/AgentBaseTests.cs index b10f82e7d434..7e272ce6bed9 100644 --- a/dotnet/test/Microsoft.AutoGen.Agents.Tests/AgentBaseTests.cs +++ b/dotnet/test/Microsoft.AutoGen.Agents.Tests/AgentBaseTests.cs @@ -1,21 +1,32 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // AgentBaseTests.cs +using System.Collections.Concurrent; using FluentAssertions; using Google.Protobuf.Reflection; using Microsoft.AutoGen.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Moq; using Xunit; +using static Microsoft.AutoGen.Agents.Tests.AgentBaseTests; namespace Microsoft.AutoGen.Agents.Tests; -public class AgentBaseTests +[Collection(ClusterFixtureCollection.Name)] +public class AgentBaseTests(InMemoryAgentRuntimeFixture fixture) { + private readonly InMemoryAgentRuntimeFixture _fixture = fixture; + [Fact] public async Task ItInvokeRightHandlerTestAsync() { var mockContext = new Mock(); + mockContext.SetupGet(x => x.AgentId).Returns(new AgentId("test", "test")); + // mock SendMessageAsync + mockContext.Setup(x => x.SendMessageAsync(It.IsAny(), It.IsAny())) + .Returns(new ValueTask()); var agent = new TestAgent(mockContext.Object, new EventTypes(TypeRegistry.Empty, [], []), new Logger(new LoggerFactory())); await agent.HandleObject("hello world"); @@ -26,12 +37,36 @@ public async Task ItInvokeRightHandlerTestAsync() agent.ReceivedItems[1].Should().Be(42); } + [Fact] + public async Task ItDelegateMessageToTestAgentAsync() + { + var client = _fixture.AppHost.Services.GetRequiredService(); + + await client.PublishMessageAsync(new TextMessage() + { + Source = nameof(ItDelegateMessageToTestAgentAsync), + TextMessage_ = "buffer" + }, token: CancellationToken.None); + + // wait for 10 seconds + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + while (!TestAgent.ReceivedMessages.ContainsKey(nameof(ItDelegateMessageToTestAgentAsync)) && !cts.Token.IsCancellationRequested) + { + await Task.Delay(100); + } + + TestAgent.ReceivedMessages[nameof(ItDelegateMessageToTestAgentAsync)].Should().NotBeNull(); + } + /// /// The test agent is a simple agent that is used for testing purposes. /// - public class TestAgent : AgentBase, IHandle, IHandle + public class TestAgent : AgentBase, IHandle, IHandle, IHandle { - public TestAgent(IAgentRuntime context, EventTypes eventTypes, Logger logger) : base(context, eventTypes, logger) + public TestAgent( + IAgentRuntime context, + [FromKeyedServices("EventTypes")] EventTypes eventTypes, + Logger? logger = null) : base(context, eventTypes, logger) { } @@ -47,6 +82,49 @@ public Task Handle(int item) return Task.CompletedTask; } + public Task Handle(TextMessage item) + { + ReceivedMessages[item.Source] = item.TextMessage_; + return Task.CompletedTask; + } + public List ReceivedItems { get; private set; } = []; + + /// + /// Key: source + /// Value: message + /// + public static ConcurrentDictionary ReceivedMessages { get; private set; } = new(); + } +} + +public sealed class InMemoryAgentRuntimeFixture : IDisposable +{ + public InMemoryAgentRuntimeFixture() + { + var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(); + + // step 1: create in-memory agent runtime + // step 2: register TestAgent to that agent runtime + builder + .AddAgentService(local: true, useGrpc: false) + .AddAgentWorker(local: true) + .AddAgent(nameof(TestAgent)); + + AppHost = builder.Build(); + AppHost.StartAsync().Wait(); + } + public IHost AppHost { get; } + + void IDisposable.Dispose() + { + AppHost.StopAsync().Wait(); + AppHost.Dispose(); } } + +[CollectionDefinition(Name)] +public sealed class ClusterFixtureCollection : ICollectionFixture +{ + public const string Name = nameof(ClusterFixtureCollection); +} diff --git a/dotnet/test/Microsoft.AutoGen.Agents.Tests/Microsoft.AutoGen.Agents.Tests.csproj b/dotnet/test/Microsoft.AutoGen.Agents.Tests/Microsoft.AutoGen.Agents.Tests.csproj index db7467bf1235..4abf1dc834d6 100644 --- a/dotnet/test/Microsoft.AutoGen.Agents.Tests/Microsoft.AutoGen.Agents.Tests.csproj +++ b/dotnet/test/Microsoft.AutoGen.Agents.Tests/Microsoft.AutoGen.Agents.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/dotnet/website/articles/Installation.md b/dotnet/website/articles/Installation.md index 30b55442d246..b421304b04be 100644 --- a/dotnet/website/articles/Installation.md +++ b/dotnet/website/articles/Installation.md @@ -32,9 +32,7 @@ dotnet add package AUTOGEN_PACKAGES ### Consume nightly build To consume nightly build, you can add one of the following feeds to your `NuGet.config` or global nuget config: -- ![Static Badge](https://img.shields.io/badge/public-blue?style=flat) ![Static Badge](https://img.shields.io/badge/github-grey?style=flat): https://nuget.pkg.github.com/microsoft/index.json -- ![Static Badge](https://img.shields.io/badge/public-blue?style=flat) ![Static Badge](https://img.shields.io/badge/myget-grey?style=flat): https://www.myget.org/F/agentchat/api/v3/index.json -- ![Static Badge](https://img.shields.io/badge/internal-blue?style=flat) ![Static Badge](https://img.shields.io/badge/azure_devops-grey?style=flat) : https://devdiv.pkgs.visualstudio.com/DevDiv/_packaging/AutoGen/nuget/v3/index.json +> - [![Static Badge](https://img.shields.io/badge/azure_devops-grey?style=flat)](https://dev.azure.com/AGPublish/AGPublic/_artifacts/feed/AutoGen-Nightly) : To add a local `NuGet.config`, create a file named `NuGet.config` in the root of your project and add the following content: ```xml @@ -42,8 +40,6 @@ To add a local `NuGet.config`, create a file named `NuGet.config` in the root of - - diff --git a/dotnet/website/articles/getting-start.md b/dotnet/website/articles/getting-start.md index fe10a597aacd..0d4bf3316364 100644 --- a/dotnet/website/articles/getting-start.md +++ b/dotnet/website/articles/getting-start.md @@ -1,6 +1,5 @@ ### Get start with AutoGen for dotnet [![dotnet-ci](https://github.com/microsoft/autogen/actions/workflows/dotnet-build.yml/badge.svg)](https://github.com/microsoft/autogen/actions/workflows/dotnet-build.yml) -[![Discord](https://img.shields.io/discord/1153072414184452236?logo=discord&style=flat)](https://discord.gg/pAbnFJrkgZ) [![NuGet version](https://badge.fury.io/nu/AutoGen.Core.svg)](https://badge.fury.io/nu/AutoGen.Core) Firstly, add `AutoGen` package to your project. diff --git a/dotnet/website/release_note/toc.yml b/dotnet/website/release_note/toc.yml index 6f070c70b861..5a423078ac64 100644 --- a/dotnet/website/release_note/toc.yml +++ b/dotnet/website/release_note/toc.yml @@ -2,7 +2,7 @@ href: 0.2.2.md - name: 0.2.1 -href: 0.2.1.md + href: 0.2.1.md - name: 0.2.0 href: 0.2.0.md @@ -17,4 +17,4 @@ href: 0.2.1.md href: 0.0.16.md - name: 0.0.0 - 0.0.15 - href: update.md \ No newline at end of file + href: update.md diff --git a/protos/agent_events.proto b/protos/agent_events.proto index 811c888c6429..5fd88bf8c441 100644 --- a/protos/agent_events.proto +++ b/protos/agent_events.proto @@ -3,7 +3,10 @@ syntax = "proto3"; package agents; option csharp_namespace = "Microsoft.AutoGen.Abstractions"; - +message TextMessage { + string textMessage = 1; + string source = 2; +} message Input { string message = 1; } diff --git a/python/.gitignore b/python/.gitignore index 186e847cc125..677a888f2f49 100644 --- a/python/.gitignore +++ b/python/.gitignore @@ -172,3 +172,6 @@ docs/**/jupyter_execute # Temporary files tmp_code_*.py + +# .NET Development settings +appsettings.Development.json \ No newline at end of file diff --git a/python/packages/agbench/README.md b/python/packages/agbench/README.md index e0b9c1c84694..a8209a1e9d25 100644 --- a/python/packages/agbench/README.md +++ b/python/packages/agbench/README.md @@ -10,7 +10,7 @@ If you are already an AutoGenBench pro, and want the full technical specificatio ## Docker Requirement -AutoGenBench also requires Docker (Desktop or Engine). **It will not run in GitHub codespaces**, unless you opt for native execution (with is strongly discouraged). To install Docker Desktop see [https://www.docker.com/products/docker-desktop/](https://www.docker.com/products/docker-desktop/). +AutoGenBench also requires Docker (Desktop or Engine). **It will not run in GitHub codespaces**, unless you opt for native execution (which is strongly discouraged). To install Docker Desktop see [https://www.docker.com/products/docker-desktop/](https://www.docker.com/products/docker-desktop/). If you are working in WSL, you can follow the instructions below to set up your environment: diff --git a/python/packages/autogen-agentchat/pyproject.toml b/python/packages/autogen-agentchat/pyproject.toml index ab5b83e5ee4d..c2336a6eeba8 100644 --- a/python/packages/autogen-agentchat/pyproject.toml +++ b/python/packages/autogen-agentchat/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "autogen-agentchat" -version = "0.4.0.dev6" +version = "0.4.0.dev7" license = {file = "LICENSE-CODE"} description = "AutoGen agents and teams library" readme = "README.md" @@ -15,7 +15,7 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ - "autogen-core==0.4.0.dev6", + "autogen-core==0.4.0.dev7", ] [tool.uv] diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/agents/__init__.py b/python/packages/autogen-agentchat/src/autogen_agentchat/agents/__init__.py index cd435bf0228a..4cff9f45822b 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/agents/__init__.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/agents/__init__.py @@ -4,6 +4,7 @@ from ._coding_assistant_agent import CodingAssistantAgent from ._society_of_mind_agent import SocietyOfMindAgent from ._tool_use_assistant_agent import ToolUseAssistantAgent +from ._user_proxy_agent import UserProxyAgent __all__ = [ "BaseChatAgent", @@ -13,4 +14,5 @@ "CodingAssistantAgent", "ToolUseAssistantAgent", "SocietyOfMindAgent", + "UserProxyAgent", ] diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/agents/_assistant_agent.py b/python/packages/autogen-agentchat/src/autogen_agentchat/agents/_assistant_agent.py index 7e502498f9b6..cb1eff8d6f6e 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/agents/_assistant_agent.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/agents/_assistant_agent.py @@ -279,8 +279,9 @@ async def on_messages_stream( return # Generate an inference result based on the current model context. + llm_messages = self._system_messages + self._model_context result = await self._model_client.create( - self._model_context, tools=self._tools + self._handoff_tools, cancellation_token=cancellation_token + llm_messages, tools=self._tools + self._handoff_tools, cancellation_token=cancellation_token ) self._model_context.append(AssistantMessage(content=result.content, source=self.name)) diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/agents/_base_chat_agent.py b/python/packages/autogen-agentchat/src/autogen_agentchat/agents/_base_chat_agent.py index 5fd4c1854cfd..622a4d491c97 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/agents/_base_chat_agent.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/agents/_base_chat_agent.py @@ -4,7 +4,7 @@ from autogen_core.base import CancellationToken from ..base import ChatAgent, Response, TaskResult -from ..messages import AgentMessage, ChatMessage, MultiModalMessage, TextMessage +from ..messages import AgentMessage, ChatMessage, HandoffMessage, MultiModalMessage, StopMessage, TextMessage class BaseChatAgent(ChatAgent, ABC): @@ -54,7 +54,7 @@ async def on_messages_stream( async def run( self, *, - task: str | TextMessage | MultiModalMessage | None = None, + task: str | ChatMessage | None = None, cancellation_token: CancellationToken | None = None, ) -> TaskResult: """Run the agent with the given task and return the result.""" @@ -62,13 +62,17 @@ async def run( cancellation_token = CancellationToken() input_messages: List[ChatMessage] = [] output_messages: List[AgentMessage] = [] - if isinstance(task, str): + if task is None: + pass + elif isinstance(task, str): text_msg = TextMessage(content=task, source="user") input_messages.append(text_msg) output_messages.append(text_msg) - elif isinstance(task, TextMessage | MultiModalMessage): + elif isinstance(task, TextMessage | MultiModalMessage | StopMessage | HandoffMessage): input_messages.append(task) output_messages.append(task) + else: + raise ValueError(f"Invalid task type: {type(task)}") response = await self.on_messages(input_messages, cancellation_token) if response.inner_messages is not None: output_messages += response.inner_messages @@ -78,7 +82,7 @@ async def run( async def run_stream( self, *, - task: str | TextMessage | MultiModalMessage | None = None, + task: str | ChatMessage | None = None, cancellation_token: CancellationToken | None = None, ) -> AsyncGenerator[AgentMessage | TaskResult, None]: """Run the agent with the given task and return a stream of messages @@ -87,15 +91,19 @@ async def run_stream( cancellation_token = CancellationToken() input_messages: List[ChatMessage] = [] output_messages: List[AgentMessage] = [] - if isinstance(task, str): + if task is None: + pass + elif isinstance(task, str): text_msg = TextMessage(content=task, source="user") input_messages.append(text_msg) output_messages.append(text_msg) yield text_msg - elif isinstance(task, TextMessage | MultiModalMessage): + elif isinstance(task, TextMessage | MultiModalMessage | StopMessage | HandoffMessage): input_messages.append(task) output_messages.append(task) yield task + else: + raise ValueError(f"Invalid task type: {type(task)}") async for message in self.on_messages_stream(input_messages, cancellation_token): if isinstance(message, Response): yield message.chat_message diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/agents/_code_executor_agent.py b/python/packages/autogen-agentchat/src/autogen_agentchat/agents/_code_executor_agent.py index 7abe68132d9d..5dd4c9008ef5 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/agents/_code_executor_agent.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/agents/_code_executor_agent.py @@ -24,9 +24,11 @@ class CodeExecutorAgent(BaseChatAgent): .. code-block:: python - + import asyncio from autogen_agentchat.agents import CodeExecutorAgent + from autogen_agentchat.messages import TextMessage from autogen_ext.code_executors import DockerCommandLineCodeExecutor + from autogen_core.base import CancellationToken async def run_code_executor_agent() -> None: @@ -51,8 +53,7 @@ async def run_code_executor_agent() -> None: await code_executor.stop() - # Use asyncio.run(run_code_executor_agent()) when running in a script. - await run_code_executor_agent() + asyncio.run(run_code_executor_agent()) """ diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/agents/_user_proxy_agent.py b/python/packages/autogen-agentchat/src/autogen_agentchat/agents/_user_proxy_agent.py new file mode 100644 index 000000000000..bdaca53ddc6c --- /dev/null +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/agents/_user_proxy_agent.py @@ -0,0 +1,89 @@ +import asyncio +from inspect import iscoroutinefunction +from typing import Awaitable, Callable, List, Optional, Sequence, Union, cast + +from autogen_core.base import CancellationToken + +from ..base import Response +from ..messages import ChatMessage, HandoffMessage, TextMessage +from ._base_chat_agent import BaseChatAgent + +# Define input function types more precisely +SyncInputFunc = Callable[[str], str] +AsyncInputFunc = Callable[[str, Optional[CancellationToken]], Awaitable[str]] +InputFuncType = Union[SyncInputFunc, AsyncInputFunc] + + +class UserProxyAgent(BaseChatAgent): + """An agent that can represent a human user in a chat.""" + + def __init__( + self, + name: str, + description: str = "a human user", + input_func: Optional[InputFuncType] = None, + ) -> None: + """Initialize the UserProxyAgent.""" + super().__init__(name=name, description=description) + self.input_func = input_func or input + self._is_async = iscoroutinefunction(self.input_func) + + @property + def produced_message_types(self) -> List[type[ChatMessage]]: + """Message types this agent can produce.""" + return [TextMessage, HandoffMessage] + + def _get_latest_handoff(self, messages: Sequence[ChatMessage]) -> Optional[HandoffMessage]: + """Find the most recent HandoffMessage in the message sequence.""" + for message in reversed(messages): + if isinstance(message, HandoffMessage): + return message + return None + + async def _get_input(self, prompt: str, cancellation_token: Optional[CancellationToken]) -> str: + """Handle input based on function signature.""" + try: + if self._is_async: + # Cast to AsyncInputFunc for proper typing + async_func = cast(AsyncInputFunc, self.input_func) + return await async_func(prompt, cancellation_token) + else: + # Cast to SyncInputFunc for proper typing + sync_func = cast(SyncInputFunc, self.input_func) + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, sync_func, prompt) + + except asyncio.CancelledError: + raise + except Exception as e: + raise RuntimeError(f"Failed to get user input: {str(e)}") from e + + async def on_messages( + self, messages: Sequence[ChatMessage], cancellation_token: Optional[CancellationToken] = None + ) -> Response: + """Handle incoming messages by requesting user input.""" + try: + # Check for handoff first + handoff = self._get_latest_handoff(messages) + prompt = ( + f"Handoff received from {handoff.source}. Enter your response: " if handoff else "Enter your response: " + ) + + user_input = await self._get_input(prompt, cancellation_token) + + # Return appropriate message type based on handoff presence + if handoff: + return Response( + chat_message=HandoffMessage(content=user_input, target=handoff.source, source=self.name) + ) + else: + return Response(chat_message=TextMessage(content=user_input, source=self.name)) + + except asyncio.CancelledError: + raise + except Exception as e: + raise RuntimeError(f"Failed to get user input: {str(e)}") from e + + async def on_reset(self, cancellation_token: Optional[CancellationToken] = None) -> None: + """Reset agent state.""" + pass diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/base/_task.py b/python/packages/autogen-agentchat/src/autogen_agentchat/base/_task.py index 0a5e37dce26b..d2cb39eb6fd2 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/base/_task.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/base/_task.py @@ -3,7 +3,7 @@ from autogen_core.base import CancellationToken -from ..messages import AgentMessage, MultiModalMessage, TextMessage +from ..messages import AgentMessage, ChatMessage @dataclass @@ -23,7 +23,7 @@ class TaskRunner(Protocol): async def run( self, *, - task: str | TextMessage | MultiModalMessage | None = None, + task: str | ChatMessage | None = None, cancellation_token: CancellationToken | None = None, ) -> TaskResult: """Run the task and return the result. @@ -36,7 +36,7 @@ async def run( def run_stream( self, *, - task: str | TextMessage | MultiModalMessage | None = None, + task: str | ChatMessage | None = None, cancellation_token: CancellationToken | None = None, ) -> AsyncGenerator[AgentMessage | TaskResult, None]: """Run the task and produces a stream of messages and the final result diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/base/_termination.py b/python/packages/autogen-agentchat/src/autogen_agentchat/base/_termination.py index 859740fa093e..c923e8ced51c 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/base/_termination.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/base/_termination.py @@ -23,15 +23,15 @@ class TerminationCondition(ABC): .. code-block:: python import asyncio - from autogen_agentchat.teams import MaxTurnsTermination, TextMentionTermination + from autogen_agentchat.task import MaxMessageTermination, TextMentionTermination async def main() -> None: # Terminate the conversation after 10 turns or if the text "TERMINATE" is mentioned. - cond1 = MaxTurnsTermination(10) | TextMentionTermination("TERMINATE") + cond1 = MaxMessageTermination(10) | TextMentionTermination("TERMINATE") # Terminate the conversation after 10 turns and if the text "TERMINATE" is mentioned. - cond2 = MaxTurnsTermination(10) & TextMentionTermination("TERMINATE") + cond2 = MaxMessageTermination(10) & TextMentionTermination("TERMINATE") # ... diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/task/__init__.py b/python/packages/autogen-agentchat/src/autogen_agentchat/task/__init__.py index 2a57b0e9cb62..d863c87cd13a 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/task/__init__.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/task/__init__.py @@ -1,7 +1,9 @@ from ._console import Console from ._terminations import ( + ExternalTermination, HandoffTermination, MaxMessageTermination, + SourceMatchTermination, StopMessageTermination, TextMentionTermination, TimeoutTermination, @@ -15,5 +17,7 @@ "TokenUsageTermination", "HandoffTermination", "TimeoutTermination", + "ExternalTermination", + "SourceMatchTermination", "Console", ] diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/task/_console.py b/python/packages/autogen-agentchat/src/autogen_agentchat/task/_console.py index 3c6464b1f07f..9899366cdc07 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/task/_console.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/task/_console.py @@ -1,7 +1,7 @@ import os import sys import time -from typing import AsyncGenerator, List +from typing import AsyncGenerator, List, Optional, TypeVar, cast from autogen_core.components import Image from autogen_core.components.models import RequestUsage @@ -18,23 +18,34 @@ def _is_output_a_tty() -> bool: return sys.stdout.isatty() +T = TypeVar("T", bound=TaskResult | Response) + + async def Console( - stream: AsyncGenerator[AgentMessage | TaskResult, None] | AsyncGenerator[AgentMessage | Response, None], + stream: AsyncGenerator[AgentMessage | T, None], *, no_inline_images: bool = False, -) -> None: - """Consume the stream from :meth:`~autogen_agentchat.base.Team.run_stream` - or :meth:`~autogen_agentchat.base.ChatAgent.on_messages_stream` - and print the messages to the console. +) -> T: + """ + Consumes the message stream from :meth:`~autogen_agentchat.base.TaskRunner.run_stream` + or :meth:`~autogen_agentchat.base.ChatAgent.on_messages_stream` and renders the messages to the console. + Returns the last processed TaskResult or Response. Args: - stream (AsyncGenerator[AgentMessage | TaskResult, None] | AsyncGenerator[AgentMessage | Response, None]): Stream to render + stream (AsyncGenerator[AgentMessage | TaskResult, None] | AsyncGenerator[AgentMessage | Response, None]): Message stream to render. + This can be from :meth:`~autogen_agentchat.base.TaskRunner.run_stream` or :meth:`~autogen_agentchat.base.ChatAgent.on_messages_stream`. no_inline_images (bool, optional): If terminal is iTerm2 will render images inline. Use this to disable this behavior. Defaults to False. - """ + Returns: + last_processed: A :class:`~autogen_agentchat.base.TaskResult` if the stream is from :meth:`~autogen_agentchat.base.TaskRunner.run_stream` + or a :class:`~autogen_agentchat.base.Response` if the stream is from :meth:`~autogen_agentchat.base.ChatAgent.on_messages_stream`. + """ render_image_iterm = _is_running_in_iterm() and _is_output_a_tty() and not no_inline_images start_time = time.time() total_usage = RequestUsage(prompt_tokens=0, completion_tokens=0) + + last_processed: Optional[T] = None + async for message in stream: if isinstance(message, TaskResult): duration = time.time() - start_time @@ -47,6 +58,9 @@ async def Console( f"Duration: {duration:.2f} seconds\n" ) sys.stdout.write(output) + # mypy ignore + last_processed = message # type: ignore + elif isinstance(message, Response): duration = time.time() - start_time @@ -71,7 +85,12 @@ async def Console( f"Duration: {duration:.2f} seconds\n" ) sys.stdout.write(output) + # mypy ignore + last_processed = message # type: ignore + else: + # Cast required for mypy to be happy + message = cast(AgentMessage, message) # type: ignore output = f"{'-' * 10} {message.source} {'-' * 10}\n{_message_to_str(message, render_image_iterm=render_image_iterm)}\n" if message.models_usage: output += f"[Prompt tokens: {message.models_usage.prompt_tokens}, Completion tokens: {message.models_usage.completion_tokens}]\n" @@ -79,6 +98,11 @@ async def Console( total_usage.prompt_tokens += message.models_usage.prompt_tokens sys.stdout.write(output) + if last_processed is None: + raise ValueError("No TaskResult or Response was processed.") + + return last_processed + # iTerm2 image rendering protocol: https://iterm2.com/documentation-images.html def _image_to_iterm(image: Image) -> str: diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/task/_terminations.py b/python/packages/autogen-agentchat/src/autogen_agentchat/task/_terminations.py index 16d44b9840d3..9db5b584a31d 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/task/_terminations.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/task/_terminations.py @@ -1,5 +1,5 @@ import time -from typing import Sequence +from typing import List, Sequence from ..base import TerminatedException, TerminationCondition from ..messages import AgentMessage, HandoffMessage, MultiModalMessage, StopMessage, TextMessage @@ -208,3 +208,81 @@ async def __call__(self, messages: Sequence[AgentMessage]) -> StopMessage | None async def reset(self) -> None: self._start_time = time.monotonic() self._terminated = False + + +class ExternalTermination(TerminationCondition): + """A termination condition that is externally controlled + by calling the :meth:`set` method. + + Example: + + .. code-block:: python + + from autogen_agentchat.task import ExternalTermination + + termination = ExternalTermination() + + # Run the team in an asyncio task. + ... + + # Set the termination condition externally + termination.set() + + """ + + def __init__(self) -> None: + self._terminated = False + self._setted = False + + @property + def terminated(self) -> bool: + return self._terminated + + def set(self) -> None: + """Set the termination condition to terminated.""" + self._setted = True + + async def __call__(self, messages: Sequence[AgentMessage]) -> StopMessage | None: + if self._terminated: + raise TerminatedException("Termination condition has already been reached") + if self._setted: + self._terminated = True + return StopMessage(content="External termination requested", source="ExternalTermination") + return None + + async def reset(self) -> None: + self._terminated = False + self._setted = False + + +class SourceMatchTermination(TerminationCondition): + """Terminate the conversation after a specific source responds. + + Args: + sources (List[str]): List of source names to terminate the conversation. + + Raises: + TerminatedException: If the termination condition has already been reached. + """ + + def __init__(self, sources: List[str]) -> None: + self._sources = sources + self._terminated = False + + @property + def terminated(self) -> bool: + return self._terminated + + async def __call__(self, messages: Sequence[AgentMessage]) -> StopMessage | None: + if self._terminated: + raise TerminatedException("Termination condition has already been reached") + if not messages: + return None + for message in messages: + if message.source in self._sources: + self._terminated = True + return StopMessage(content=f"'{message.source}' answered", source="SourceMatchTermination") + return None + + async def reset(self) -> None: + self._terminated = False diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_base_group_chat.py b/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_base_group_chat.py index c41ebaf461bf..fbca2644920a 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_base_group_chat.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_base_group_chat.py @@ -12,13 +12,12 @@ AgentType, CancellationToken, MessageContext, - TopicId, ) from autogen_core.components import ClosureAgent, TypeSubscription from ... import EVENT_LOGGER_NAME from ...base import ChatAgent, TaskResult, Team, TerminationCondition -from ...messages import AgentMessage, MultiModalMessage, TextMessage +from ...messages import AgentMessage, ChatMessage, HandoffMessage, MultiModalMessage, StopMessage, TextMessage from ._chat_agent_container import ChatAgentContainer from ._events import GroupChatMessage, GroupChatReset, GroupChatStart, GroupChatTermination from ._sequential_routed_agent import SequentialRoutedAgent @@ -164,7 +163,7 @@ async def collect_output_messages( async def run( self, *, - task: str | TextMessage | MultiModalMessage | None = None, + task: str | ChatMessage | None = None, cancellation_token: CancellationToken | None = None, ) -> TaskResult: """Run the team and return the result. The base implementation uses @@ -215,7 +214,7 @@ async def main() -> None: async def run_stream( self, *, - task: str | TextMessage | MultiModalMessage | None = None, + task: str | ChatMessage | None = None, cancellation_token: CancellationToken | None = None, ) -> AsyncGenerator[AgentMessage | TaskResult, None]: """Run the team and produces a stream of messages and the final result @@ -253,6 +252,16 @@ async def main() -> None: asyncio.run(main()) """ + # Create the first chat message if the task is a string or a chat message. + first_chat_message: ChatMessage | None = None + if task is None: + pass + elif isinstance(task, str): + first_chat_message = TextMessage(content=task, source="user") + elif isinstance(task, TextMessage | MultiModalMessage | StopMessage | HandoffMessage): + first_chat_message = task + else: + raise ValueError(f"Invalid task type: {type(task)}") if self._is_running: raise ValueError("The team is already running, it cannot run again until it is stopped.") @@ -265,17 +274,6 @@ async def main() -> None: if not self._initialized: await self._init(self._runtime) - # Run the team by publishing the start message. - first_chat_message: TextMessage | MultiModalMessage | None = None - if isinstance(task, str): - first_chat_message = TextMessage(content=task, source="user") - elif isinstance(task, TextMessage | MultiModalMessage): - first_chat_message = task - await self._runtime.publish_message( - GroupChatStart(message=first_chat_message), - topic_id=TopicId(type=self._group_topic_type, source=self._team_id), - ) - # Start a coroutine to stop the runtime and signal the output message queue is complete. async def stop_runtime() -> None: await self._runtime.stop_when_idle() @@ -283,24 +281,37 @@ async def stop_runtime() -> None: shutdown_task = asyncio.create_task(stop_runtime()) - # Collect the output messages in order. - output_messages: List[AgentMessage] = [] - # Yield the messsages until the queue is empty. - while True: - message = await self._output_message_queue.get() - if message is None: - break - yield message - output_messages.append(message) - - # Wait for the shutdown task to finish. - await shutdown_task - - # Yield the final result. - yield TaskResult(messages=output_messages, stop_reason=self._stop_reason) - - # Indicate that the team is no longer running. - self._is_running = False + try: + # Run the team by sending the start message to the group chat manager. + # The group chat manager will start the group chat by relaying the message to the participants + # and the closure agent. + await self._runtime.send_message( + GroupChatStart(message=first_chat_message), + recipient=AgentId(type=self._group_chat_manager_topic_type, key=self._team_id), + ) + # Collect the output messages in order. + output_messages: List[AgentMessage] = [] + # Yield the messsages until the queue is empty. + while True: + message = await self._output_message_queue.get() + if message is None: + break + yield message + output_messages.append(message) + + # Yield the final result. + yield TaskResult(messages=output_messages, stop_reason=self._stop_reason) + + finally: + # Wait for the shutdown task to finish. + await shutdown_task + + # Clear the output message queue. + while not self._output_message_queue.empty(): + self._output_message_queue.get_nowait() + + # Indicate that the team is no longer running. + self._is_running = False async def reset(self) -> None: """Reset the team and its participants to their initial state. @@ -352,19 +363,26 @@ async def main() -> None: # Start the runtime. self._runtime.start() - # Send a reset message to the group chat. - await self._runtime.publish_message( - GroupChatReset(), - topic_id=TopicId(type=self._group_topic_type, source=self._team_id), - ) - - # Stop the runtime. - await self._runtime.stop_when_idle() + try: + # Send a reset messages to all participants. + for participant_topic_type in self._participant_topic_types: + await self._runtime.send_message( + GroupChatReset(), + recipient=AgentId(type=participant_topic_type, key=self._team_id), + ) + # Send a reset message to the group chat manager. + await self._runtime.send_message( + GroupChatReset(), + recipient=AgentId(type=self._group_chat_manager_topic_type, key=self._team_id), + ) + finally: + # Stop the runtime. + await self._runtime.stop_when_idle() - # Reset the output message queue. - self._stop_reason = None - while not self._output_message_queue.empty(): - self._output_message_queue.get_nowait() + # Reset the output message queue. + self._stop_reason = None + while not self._output_message_queue.empty(): + self._output_message_queue.get_nowait() - # Indicate that the team is no longer running. - self._is_running = False + # Indicate that the team is no longer running. + self._is_running = False diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_base_group_chat_manager.py b/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_base_group_chat_manager.py index e28f46a8c89d..d2a2b917690b 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_base_group_chat_manager.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_base_group_chat_manager.py @@ -2,10 +2,10 @@ from typing import Any, List from autogen_core.base import MessageContext -from autogen_core.components import DefaultTopicId, event +from autogen_core.components import DefaultTopicId, event, rpc from ...base import TerminationCondition -from ...messages import AgentMessage, StopMessage +from ...messages import AgentMessage, ChatMessage, StopMessage from ._events import ( GroupChatAgentResponse, GroupChatRequestPublish, @@ -55,7 +55,7 @@ def __init__( self._max_turns = max_turns self._current_turn = 0 - @event + @rpc async def handle_start(self, message: GroupChatStart, ctx: MessageContext) -> None: """Handle the start of a group chat by selecting a speaker to start the conversation.""" @@ -70,10 +70,16 @@ async def handle_start(self, message: GroupChatStart, ctx: MessageContext) -> No # Stop the group chat. return + # Validate the group state given the start message. + await self.validate_group_state(message.message) + if message.message is not None: # Log the start message. await self.publish_message(message, topic_id=DefaultTopicId(type=self._output_topic_type)) + # Relay the start message to the participants. + await self.publish_message(message, topic_id=DefaultTopicId(type=self._group_topic_type)) + # Append the user message to the message thread. self._message_thread.append(message.message) @@ -137,11 +143,16 @@ async def handle_agent_response(self, message: GroupChatAgentResponse, ctx: Mess speaker_topic_type = await self.select_speaker(self._message_thread) await self.publish_message(GroupChatRequestPublish(), topic_id=DefaultTopicId(type=speaker_topic_type)) - @event + @rpc async def handle_reset(self, message: GroupChatReset, ctx: MessageContext) -> None: # Reset the group chat manager. await self.reset() + @abstractmethod + async def validate_group_state(self, message: ChatMessage | None) -> None: + """Validate the state of the group chat given the start message. This is executed when the group chat manager receives a GroupChatStart event.""" + ... + @abstractmethod async def select_speaker(self, thread: List[AgentMessage]) -> str: """Select a speaker from the participants and return the diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_chat_agent_container.py b/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_chat_agent_container.py index d249676fda06..315708032865 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_chat_agent_container.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_chat_agent_container.py @@ -1,7 +1,7 @@ from typing import Any, List from autogen_core.base import MessageContext -from autogen_core.components import DefaultTopicId, event +from autogen_core.components import DefaultTopicId, event, rpc from ...base import ChatAgent, Response from ...messages import ChatMessage @@ -38,7 +38,7 @@ async def handle_agent_response(self, message: GroupChatAgentResponse, ctx: Mess """Handle an agent response event by appending the content to the buffer.""" self._message_buffer.append(message.agent_response.chat_message) - @event + @rpc async def handle_reset(self, message: GroupChatReset, ctx: MessageContext) -> None: """Handle a reset event by resetting the agent.""" self._message_buffer.clear() diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_magentic_one/_magentic_one_orchestrator.py b/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_magentic_one/_magentic_one_orchestrator.py index 5410673ebde2..ae1567a7daec 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_magentic_one/_magentic_one_orchestrator.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_magentic_one/_magentic_one_orchestrator.py @@ -2,7 +2,7 @@ from typing import Any, List from autogen_core.base import MessageContext -from autogen_core.components import DefaultTopicId, Image, event +from autogen_core.components import DefaultTopicId, Image, event, rpc from autogen_core.components.models import ( AssistantMessage, ChatCompletionClient, @@ -102,7 +102,7 @@ def _get_task_ledger_plan_update_prompt(self, team: str) -> str: def _get_final_answer_prompt(self, task: str) -> str: return ORCHESTRATOR_FINAL_ANSWER_PROMPT.format(task=task) - @event + @rpc async def handle_start(self, message: GroupChatStart, ctx: MessageContext) -> None: """Handle the start of a group chat by selecting a speaker to start the conversation.""" assert message is not None and message.message is not None @@ -145,7 +145,7 @@ async def handle_agent_response(self, message: GroupChatAgentResponse, ctx: Mess self._message_thread.append(message.agent_response.chat_message) await self._orchestrate_step() - @event + @rpc async def handle_reset(self, message: GroupChatReset, ctx: MessageContext) -> None: # Reset the group chat manager. await self.reset() diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_round_robin_group_chat.py b/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_round_robin_group_chat.py index 8e3a262a85da..f5c128a6cc27 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_round_robin_group_chat.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_round_robin_group_chat.py @@ -1,7 +1,7 @@ from typing import Callable, List from ...base import ChatAgent, TerminationCondition -from ...messages import AgentMessage +from ...messages import AgentMessage, ChatMessage from ._base_group_chat import BaseGroupChat from ._base_group_chat_manager import BaseGroupChatManager @@ -28,6 +28,9 @@ def __init__( ) self._next_speaker_index = 0 + async def validate_group_state(self, message: ChatMessage | None) -> None: + pass + async def reset(self) -> None: self._current_turn = 0 self._message_thread.clear() @@ -68,7 +71,7 @@ class RoundRobinGroupChat(BaseGroupChat): from autogen_ext.models import OpenAIChatCompletionClient from autogen_agentchat.agents import AssistantAgent from autogen_agentchat.teams import RoundRobinGroupChat - from autogen_agentchat.task import TextMentionTermination + from autogen_agentchat.task import TextMentionTermination, Console async def main() -> None: @@ -84,9 +87,7 @@ async def get_weather(location: str) -> str: ) termination = TextMentionTermination("TERMINATE") team = RoundRobinGroupChat([assistant], termination_condition=termination) - stream = team.run_stream("What's the weather in New York?") - async for message in stream: - print(message) + await Console(team.run_stream(task="What's the weather in New York?")) asyncio.run(main()) @@ -99,7 +100,7 @@ async def get_weather(location: str) -> str: from autogen_ext.models import OpenAIChatCompletionClient from autogen_agentchat.agents import AssistantAgent from autogen_agentchat.teams import RoundRobinGroupChat - from autogen_agentchat.task import TextMentionTermination + from autogen_agentchat.task import TextMentionTermination, Console async def main() -> None: @@ -109,9 +110,7 @@ async def main() -> None: agent2 = AssistantAgent("Assistant2", model_client=model_client) termination = TextMentionTermination("TERMINATE") team = RoundRobinGroupChat([agent1, agent2], termination_condition=termination) - stream = team.run_stream("Tell me some jokes.") - async for message in stream: - print(message) + await Console(team.run_stream(task="Tell me some jokes.")) asyncio.run(main()) diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_selector_group_chat.py b/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_selector_group_chat.py index f618c0d3832c..cfcebd3783ce 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_selector_group_chat.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_selector_group_chat.py @@ -8,6 +8,7 @@ from ...base import ChatAgent, TerminationCondition from ...messages import ( AgentMessage, + ChatMessage, HandoffMessage, MultiModalMessage, StopMessage, @@ -53,6 +54,9 @@ def __init__( self._allow_repeated_speaker = allow_repeated_speaker self._selector_func = selector_func + async def validate_group_state(self, message: ChatMessage | None) -> None: + pass + async def reset(self) -> None: self._current_turn = 0 self._message_thread.clear() @@ -204,7 +208,7 @@ class SelectorGroupChat(BaseGroupChat): from autogen_ext.models import OpenAIChatCompletionClient from autogen_agentchat.agents import AssistantAgent from autogen_agentchat.teams import SelectorGroupChat - from autogen_agentchat.task import TextMentionTermination + from autogen_agentchat.task import TextMentionTermination, Console async def main() -> None: @@ -243,9 +247,7 @@ async def book_trip() -> str: model_client=model_client, termination_condition=termination, ) - stream = team.run_stream("Book a 3-day trip to new york.") - async for message in stream: - print(message) + await Console(team.run_stream(task="Book a 3-day trip to new york.")) asyncio.run(main()) @@ -255,16 +257,18 @@ async def book_trip() -> str: .. code-block:: python import asyncio + from typing import Sequence from autogen_ext.models import OpenAIChatCompletionClient from autogen_agentchat.agents import AssistantAgent from autogen_agentchat.teams import SelectorGroupChat - from autogen_agentchat.task import TextMentionTermination + from autogen_agentchat.task import TextMentionTermination, Console + from autogen_agentchat.messages import AgentMessage async def main() -> None: model_client = OpenAIChatCompletionClient(model="gpt-4o") - def check_caculation(x: int, y: int, answer: int) -> str: + def check_calculation(x: int, y: int, answer: int) -> str: if x + y == answer: return "Correct!" else: @@ -279,12 +283,12 @@ def check_caculation(x: int, y: int, answer: int) -> str: agent2 = AssistantAgent( "Agent2", model_client, - tools=[check_caculation], + tools=[check_calculation], description="For checking calculation", system_message="Check the answer and respond with 'Correct!' or 'Incorrect!'", ) - def selector_func(messages): + def selector_func(messages: Sequence[AgentMessage]) -> str | None: if len(messages) == 1 or messages[-1].content == "Incorrect!": return "Agent1" if messages[-1].source == "Agent1": @@ -299,9 +303,7 @@ def selector_func(messages): termination_condition=termination, ) - stream = team.run_stream("What is 1 + 1?") - async for message in stream: - print(message) + await Console(team.run_stream(task="What is 1 + 1?")) asyncio.run(main()) diff --git a/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_swarm_group_chat.py b/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_swarm_group_chat.py index 651367169b67..0e658ab75237 100644 --- a/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_swarm_group_chat.py +++ b/python/packages/autogen-agentchat/src/autogen_agentchat/teams/_group_chat/_swarm_group_chat.py @@ -3,7 +3,7 @@ from ... import EVENT_LOGGER_NAME from ...base import ChatAgent, TerminationCondition -from ...messages import AgentMessage, HandoffMessage +from ...messages import AgentMessage, ChatMessage, HandoffMessage from ._base_group_chat import BaseGroupChat from ._base_group_chat_manager import BaseGroupChatManager @@ -32,6 +32,31 @@ def __init__( ) self._current_speaker = participant_topic_types[0] + async def validate_group_state(self, message: ChatMessage | None) -> None: + """Validate the start message for the group chat.""" + # Check if the start message is a handoff message. + if isinstance(message, HandoffMessage): + if message.target not in self._participant_topic_types: + raise ValueError( + f"The target {message.target} is not one of the participants {self._participant_topic_types}. " + "If you are resuming Swarm with a new HandoffMessage make sure to set the target to a valid participant as the target." + ) + return + # Check if there is a handoff message in the thread that is not targeting a valid participant. + for existing_message in reversed(self._message_thread): + if isinstance(existing_message, HandoffMessage): + if existing_message.target not in self._participant_topic_types: + raise ValueError( + f"The existing handoff target {existing_message.target} is not one of the participants {self._participant_topic_types}. " + "If you are resuming Swarm with a new task make sure to include in your task " + "a HandoffMessage with a valid participant as the target. For example, if you are " + "resuming from a HandoffTermination, make sure the new task is a HandoffMessage " + "with a valid participant as the target." + ) + # The latest handoff message should always target a valid participant. + # Do not look past the latest handoff message. + return + async def reset(self) -> None: self._current_turn = 0 self._message_thread.clear() @@ -47,13 +72,8 @@ async def select_speaker(self, thread: List[AgentMessage]) -> str: for message in reversed(thread): if isinstance(message, HandoffMessage): self._current_speaker = message.target - if self._current_speaker not in self._participant_topic_types: - raise ValueError( - f"The target {self._current_speaker} in the handoff message " - f"is not one of the participants {self._participant_topic_types}. " - "If you are resuming the Swarm with a new task make sure to include in your task " - "a handoff message with a valid participant as the target." - ) + # The latest handoff message should always target a valid participant. + assert self._current_speaker in self._participant_topic_types return self._current_speaker return self._current_speaker @@ -72,7 +92,7 @@ class Swarm(BaseGroupChat): Without a termination condition, the group chat will run indefinitely. max_turns (int, optional): The maximum number of turns in the group chat before stopping. Defaults to None, meaning no limit. - Examples: + Basic example: .. code-block:: python @@ -99,11 +119,49 @@ async def main() -> None: termination = MaxMessageTermination(3) team = Swarm([agent1, agent2], termination_condition=termination) - stream = team.run_stream("What is bob's birthday?") + stream = team.run_stream(task="What is bob's birthday?") async for message in stream: print(message) + asyncio.run(main()) + + + Using the :class:`~autogen_agentchat.task.HandoffTermination` for human-in-the-loop handoff: + + .. code-block:: python + + import asyncio + from autogen_ext.models import OpenAIChatCompletionClient + from autogen_agentchat.agents import AssistantAgent + from autogen_agentchat.teams import Swarm + from autogen_agentchat.task import HandoffTermination, Console, MaxMessageTermination + from autogen_agentchat.messages import HandoffMessage + + + async def main() -> None: + model_client = OpenAIChatCompletionClient(model="gpt-4o") + + agent = AssistantAgent( + "Alice", + model_client=model_client, + handoffs=["user"], + system_message="You are Alice and you only answer questions about yourself, ask the user for help if needed.", + ) + termination = HandoffTermination(target="user") | MaxMessageTermination(3) + team = Swarm([agent], termination_condition=termination) + + # Start the conversation. + await Console(team.run_stream(task="What is bob's birthday?")) + + # Resume with user feedback. + await Console( + team.run_stream( + task=HandoffMessage(source="user", target="Alice", content="Bob's birthday is on 1st January.") + ) + ) + + asyncio.run(main()) """ diff --git a/python/packages/autogen-agentchat/tests/test_group_chat.py b/python/packages/autogen-agentchat/tests/test_group_chat.py index db8bfa9d4167..7df27abcbcd5 100644 --- a/python/packages/autogen-agentchat/tests/test_group_chat.py +++ b/python/packages/autogen-agentchat/tests/test_group_chat.py @@ -24,7 +24,7 @@ ToolCallMessage, ToolCallResultMessage, ) -from autogen_agentchat.task import MaxMessageTermination, TextMentionTermination +from autogen_agentchat.task import Console, HandoffTermination, MaxMessageTermination, TextMentionTermination from autogen_agentchat.teams import ( RoundRobinGroupChat, SelectorGroupChat, @@ -315,6 +315,14 @@ async def test_round_robin_group_chat_with_tools(monkeypatch: pytest.MonkeyPatch assert message == result.messages[index] index += 1 + # Test Console. + tool_use_agent._model_context.clear() # pyright: ignore + mock.reset() + index = 0 + await team.reset() + result2 = await Console(team.run_stream(task="Write a program that prints 'Hello, world!'")) + assert result2 == result + @pytest.mark.asyncio async def test_round_robin_group_chat_with_resume_and_reset() -> None: @@ -476,6 +484,14 @@ async def test_selector_group_chat(monkeypatch: pytest.MonkeyPatch) -> None: assert message == result.messages[index] index += 1 + # Test Console. + mock.reset() + agent1._count = 0 # pyright: ignore + index = 0 + await team.reset() + result2 = await Console(team.run_stream(task="Write a program that prints 'Hello, world!'")) + assert result2 == result + @pytest.mark.asyncio async def test_selector_group_chat_two_speakers(monkeypatch: pytest.MonkeyPatch) -> None: @@ -528,6 +544,14 @@ async def test_selector_group_chat_two_speakers(monkeypatch: pytest.MonkeyPatch) assert message == result.messages[index] index += 1 + # Test Console. + mock.reset() + agent1._count = 0 # pyright: ignore + index = 0 + await team.reset() + result2 = await Console(team.run_stream(task="Write a program that prints 'Hello, world!'")) + assert result2 == result + @pytest.mark.asyncio async def test_selector_group_chat_two_speakers_allow_repeated(monkeypatch: pytest.MonkeyPatch) -> None: @@ -595,6 +619,13 @@ async def test_selector_group_chat_two_speakers_allow_repeated(monkeypatch: pyte assert message == result.messages[index] index += 1 + # Test Console. + mock.reset() + index = 0 + await team.reset() + result2 = await Console(team.run_stream(task="Write a program that prints 'Hello, world!'")) + assert result2 == result + @pytest.mark.asyncio async def test_selector_group_chat_custom_selector(monkeypatch: pytest.MonkeyPatch) -> None: @@ -792,6 +823,14 @@ async def test_swarm_handoff_using_tool_calls(monkeypatch: pytest.MonkeyPatch) - assert message == result.messages[index] index += 1 + # Test Console + agent1._model_context.clear() # pyright: ignore + mock.reset() + index = 0 + await team.reset() + result2 = await Console(team.run_stream(task="task")) + assert result2 == result + @pytest.mark.asyncio async def test_swarm_pause_and_resume() -> None: @@ -815,3 +854,56 @@ async def test_swarm_pause_and_resume() -> None: result = await team.run() assert len(result.messages) == 1 assert result.messages[0].content == "Transferred to second_agent." + + +@pytest.mark.asyncio +async def test_swarm_with_handoff_termination() -> None: + first_agent = _HandOffAgent("first_agent", description="first agent", next_agent="second_agent") + second_agent = _HandOffAgent("second_agent", description="second agent", next_agent="third_agent") + third_agent = _HandOffAgent("third_agent", description="third agent", next_agent="first_agent") + + # Handoff to an existing agent. + termination = HandoffTermination(target="third_agent") + team = Swarm([second_agent, first_agent, third_agent], termination_condition=termination) + # Start + result = await team.run(task="task") + assert len(result.messages) == 2 + assert result.messages[0].content == "task" + assert result.messages[1].content == "Transferred to third_agent." + # Resume existing. + result = await team.run() + assert len(result.messages) == 3 + assert result.messages[0].content == "Transferred to first_agent." + assert result.messages[1].content == "Transferred to second_agent." + assert result.messages[2].content == "Transferred to third_agent." + # Resume new task. + result = await team.run(task="new task") + assert len(result.messages) == 4 + assert result.messages[0].content == "new task" + assert result.messages[1].content == "Transferred to first_agent." + assert result.messages[2].content == "Transferred to second_agent." + assert result.messages[3].content == "Transferred to third_agent." + + # Handoff to a non-existing agent. + third_agent = _HandOffAgent("third_agent", description="third agent", next_agent="non_existing_agent") + termination = HandoffTermination(target="non_existing_agent") + team = Swarm([second_agent, first_agent, third_agent], termination_condition=termination) + # Start + result = await team.run(task="task") + assert len(result.messages) == 3 + assert result.messages[0].content == "task" + assert result.messages[1].content == "Transferred to third_agent." + assert result.messages[2].content == "Transferred to non_existing_agent." + # Attempt to resume. + with pytest.raises(ValueError): + await team.run() + # Attempt to resume with a new task. + with pytest.raises(ValueError): + await team.run(task="new task") + # Resume with a HandoffMessage + result = await team.run(task=HandoffMessage(content="Handoff to first_agent.", target="first_agent", source="user")) + assert len(result.messages) == 4 + assert result.messages[0].content == "Handoff to first_agent." + assert result.messages[1].content == "Transferred to second_agent." + assert result.messages[2].content == "Transferred to third_agent." + assert result.messages[3].content == "Transferred to non_existing_agent." diff --git a/python/packages/autogen-agentchat/tests/test_termination_condition.py b/python/packages/autogen-agentchat/tests/test_termination_condition.py index a56d8df356cc..ec6ff43e00ce 100644 --- a/python/packages/autogen-agentchat/tests/test_termination_condition.py +++ b/python/packages/autogen-agentchat/tests/test_termination_condition.py @@ -1,10 +1,13 @@ import asyncio import pytest +from autogen_agentchat.base import TerminatedException from autogen_agentchat.messages import HandoffMessage, StopMessage, TextMessage from autogen_agentchat.task import ( + ExternalTermination, HandoffTermination, MaxMessageTermination, + SourceMatchTermination, StopMessageTermination, TextMentionTermination, TimeoutTermination, @@ -226,3 +229,41 @@ async def test_timeout_termination() -> None: assert await termination([TextMessage(content="Hello", source="user")]) is None await asyncio.sleep(0.2) assert await termination([TextMessage(content="World", source="user")]) is not None + + +@pytest.mark.asyncio +async def test_external_termination() -> None: + termination = ExternalTermination() + + assert await termination([]) is None + assert not termination.terminated + + termination.set() + assert await termination([]) is not None + assert termination.terminated + + await termination.reset() + assert await termination([]) is None + + +@pytest.mark.asyncio +async def test_source_match_termination() -> None: + termination = SourceMatchTermination(sources=["Assistant"]) + assert await termination([]) is None + + continue_messages = [TextMessage(content="Hello", source="agent"), TextMessage(content="Hello", source="user")] + assert await termination(continue_messages) is None + + terminate_messages = [ + TextMessage(content="Hello", source="agent"), + TextMessage(content="Hello", source="Assistant"), + TextMessage(content="Hello", source="user"), + ] + result = await termination(terminate_messages) + assert isinstance(result, StopMessage) + assert termination.terminated + + with pytest.raises(TerminatedException): + await termination([]) + await termination.reset() + assert not termination.terminated diff --git a/python/packages/autogen-agentchat/tests/test_userproxy_agent.py b/python/packages/autogen-agentchat/tests/test_userproxy_agent.py new file mode 100644 index 000000000000..2ef3053f09bf --- /dev/null +++ b/python/packages/autogen-agentchat/tests/test_userproxy_agent.py @@ -0,0 +1,103 @@ +import asyncio +from typing import Optional, Sequence + +import pytest +from autogen_agentchat.agents import UserProxyAgent +from autogen_agentchat.base import Response +from autogen_agentchat.messages import ChatMessage, HandoffMessage, TextMessage +from autogen_core.base import CancellationToken + + +@pytest.mark.asyncio +async def test_basic_input() -> None: + """Test basic message handling with custom input""" + + def custom_input(prompt: str) -> str: + return "The height of the eiffel tower is 324 meters. Aloha!" + + agent = UserProxyAgent(name="test_user", input_func=custom_input) + messages = [TextMessage(content="What is the height of the eiffel tower?", source="assistant")] + + response = await agent.on_messages(messages, CancellationToken()) + + assert isinstance(response, Response) + assert isinstance(response.chat_message, TextMessage) + assert response.chat_message.content == "The height of the eiffel tower is 324 meters. Aloha!" + assert response.chat_message.source == "test_user" + + +@pytest.mark.asyncio +async def test_async_input() -> None: + """Test handling of async input function""" + + async def async_input(prompt: str, token: Optional[CancellationToken] = None) -> str: + await asyncio.sleep(0.1) + return "async response" + + agent = UserProxyAgent(name="test_user", input_func=async_input) + messages = [TextMessage(content="test prompt", source="assistant")] + + response = await agent.on_messages(messages, CancellationToken()) + + assert isinstance(response.chat_message, TextMessage) + assert response.chat_message.content == "async response" + assert response.chat_message.source == "test_user" + + +@pytest.mark.asyncio +async def test_handoff_handling() -> None: + """Test handling of handoff messages""" + + def custom_input(prompt: str) -> str: + return "handoff response" + + agent = UserProxyAgent(name="test_user", input_func=custom_input) + + messages: Sequence[ChatMessage] = [ + TextMessage(content="Initial message", source="assistant"), + HandoffMessage(content="Handing off to user for confirmation", source="assistant", target="test_user"), + ] + + response = await agent.on_messages(messages, CancellationToken()) + + assert isinstance(response.chat_message, HandoffMessage) + assert response.chat_message.content == "handoff response" + assert response.chat_message.source == "test_user" + assert response.chat_message.target == "assistant" + + +@pytest.mark.asyncio +async def test_cancellation() -> None: + """Test cancellation during message handling""" + + async def cancellable_input(prompt: str, token: Optional[CancellationToken] = None) -> str: + await asyncio.sleep(0.1) + if token and token.is_cancelled(): + raise asyncio.CancelledError() + return "cancellable response" + + agent = UserProxyAgent(name="test_user", input_func=cancellable_input) + messages = [TextMessage(content="test prompt", source="assistant")] + token = CancellationToken() + + async def cancel_after_delay() -> None: + await asyncio.sleep(0.05) + token.cancel() + + with pytest.raises(asyncio.CancelledError): + await asyncio.gather(agent.on_messages(messages, token), cancel_after_delay()) + + +@pytest.mark.asyncio +async def test_error_handling() -> None: + """Test error handling with problematic input function""" + + def failing_input(_: str) -> str: + raise ValueError("Input function failed") + + agent = UserProxyAgent(name="test_user", input_func=failing_input) + messages = [TextMessage(content="test prompt", source="assistant")] + + with pytest.raises(RuntimeError) as exc_info: + await agent.on_messages(messages, CancellationToken()) + assert "Failed to get user input" in str(exc_info.value) diff --git a/python/packages/autogen-core/docs/drawio/selector-group-chat.drawio b/python/packages/autogen-core/docs/drawio/selector-group-chat.drawio new file mode 100644 index 000000000000..ad363bfd7fa1 --- /dev/null +++ b/python/packages/autogen-core/docs/drawio/selector-group-chat.drawio @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/python/packages/autogen-core/docs/drawio/swarm_customer_support.drawio b/python/packages/autogen-core/docs/drawio/swarm_customer_support.drawio new file mode 100644 index 000000000000..798b921cd5ef --- /dev/null +++ b/python/packages/autogen-core/docs/drawio/swarm_customer_support.drawio @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/python/packages/autogen-core/docs/drawio/swarm_stock_research.drawio b/python/packages/autogen-core/docs/drawio/swarm_stock_research.drawio new file mode 100644 index 000000000000..83d699e5decd --- /dev/null +++ b/python/packages/autogen-core/docs/drawio/swarm_stock_research.drawio @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/python/packages/autogen-core/docs/src/_extension/code_lint.py b/python/packages/autogen-core/docs/src/_extension/code_lint.py new file mode 100644 index 000000000000..b8c01bfd069f --- /dev/null +++ b/python/packages/autogen-core/docs/src/_extension/code_lint.py @@ -0,0 +1,98 @@ +# Modified from: https://github.com/kai687/sphinxawesome-codelinter + +import tempfile +from typing import AbstractSet, Any, Iterable + +from docutils import nodes +from sphinx.application import Sphinx +from sphinx.builders import Builder +from sphinx.util import logging +from sphinx.util.console import darkgreen, darkred, red, teal, faint # type: ignore[attr-defined] + +from pygments import highlight # type: ignore +from pygments.lexers import PythonLexer +from pygments.formatters import TerminalFormatter + +logger = logging.getLogger(__name__) + +__version__ = "0.1.0" + + +class CodeLinter(Builder): + """Iterate over all ``literal_block`` nodes. + + pipe them into any command line tool that + can read from standard input. + """ + + name = "code_lint" + allow_parallel = True + + def init(self) -> None: + """Initialize.""" + self._had_errors = False + pass + + def get_outdated_docs(self) -> str | Iterable[str]: + """Check for outdated files. + + Return an iterable of outdated output files, or a string describing what an + update will build. + """ + return self.env.found_docs + + def get_target_uri(self, docname: str, typ: str | None = None) -> str: + """Return Target URI for a document name.""" + return "" + + def prepare_writing(self, docnames: AbstractSet[str]) -> None: + """Run these steps before documents are written.""" + return + + def write_doc(self, docname: str, doctree: nodes.Node) -> None: + path_prefix: str = self.app.config.code_lint_path_prefix + supported_languages = set(["python"]) + + if not docname.startswith(path_prefix): + return + + for code in doctree.findall(nodes.literal_block): + if code["language"] in supported_languages: + logger.info("Checking a code block in %s...", docname, nonl=True) + if "ignore" in code["classes"]: + logger.info(" " + darkgreen("OK[ignored]")) + continue + + # Create a temporary file to store the code block + with tempfile.NamedTemporaryFile(mode="wb", suffix=".py") as temp_file: + temp_file.write(code.astext().encode()) + temp_file.flush() + + # Run pyright on the temporary file using subprocess.run + import subprocess + + result = subprocess.run(["pyright", temp_file.name], capture_output=True, text=True) + if result.returncode != 0: + logger.info(" " + darkred("FAIL")) + highlighted_code = highlight(code.astext(), PythonLexer(), TerminalFormatter()) # type: ignore + output = f"{faint('========================================================')}\n{red('Error')}: Pyright found issues in {teal(docname)}:\n{faint('--------------------------------------------------------')}\n{highlighted_code}\n{faint('--------------------------------------------------------')}\n\n{teal('pyright output:')}\n{red(result.stdout)}{faint('========================================================')}\n" + logger.info(output) + self._had_errors = True + else: + logger.info(" " + darkgreen("OK")) + + def finish(self) -> None: + """Finish the build process.""" + if self._had_errors: + raise RuntimeError("Code linting failed - see earlier output") + + +def setup(app: Sphinx) -> dict[str, Any]: + app.add_builder(CodeLinter) + app.add_config_value("code_lint_path_prefix", "", "env") + + return { + "version": __version__, + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/python/packages/autogen-core/docs/src/conf.py b/python/packages/autogen-core/docs/src/conf.py index 11341873d141..9852428ae436 100644 --- a/python/packages/autogen-core/docs/src/conf.py +++ b/python/packages/autogen-core/docs/src/conf.py @@ -37,7 +37,8 @@ "sphinx_copybutton", "_extension.gallery_directive", "myst_nb", - "sphinxcontrib.autodoc_pydantic" + "sphinxcontrib.autodoc_pydantic", + "_extension.code_lint", ] suppress_warnings = ["myst.header"] @@ -148,6 +149,14 @@ intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} +code_lint_path_prefix = "reference/python" + +nb_mime_priority_overrides = [ + ('code_lint', 'image/jpeg', 100), + ('code_lint', 'image/png', 100), + ('code_lint', 'text/plain', 100) +] + def setup_to_main( app: Sphinx, pagename: str, templatename: str, context, doctree diff --git a/python/packages/autogen-core/docs/src/index.md b/python/packages/autogen-core/docs/src/index.md index 674492e3f231..bee058bd9124 100644 --- a/python/packages/autogen-core/docs/src/index.md +++ b/python/packages/autogen-core/docs/src/index.md @@ -61,7 +61,7 @@ AgentChat High-level API that includes preset agents and teams for building multi-agent systems. ```sh -pip install 'autogen-agentchat==0.4.0.dev6' +pip install 'autogen-agentchat==0.4.0.dev7' ``` 💡 *Start here if you are looking for an API similar to AutoGen 0.2* @@ -82,7 +82,7 @@ Get Started Provides building blocks for creating asynchronous, event driven multi-agent systems. ```sh -pip install 'autogen-core==0.4.0.dev6' +pip install 'autogen-core==0.4.0.dev7' ``` +++ diff --git a/python/packages/autogen-core/docs/src/packages/index.md b/python/packages/autogen-core/docs/src/packages/index.md index dd86c4c95122..b659482e0d67 100644 --- a/python/packages/autogen-core/docs/src/packages/index.md +++ b/python/packages/autogen-core/docs/src/packages/index.md @@ -31,10 +31,10 @@ myst: Library that is at a similar level of abstraction as AutoGen 0.2, including default agents and group chat. ```sh -pip install 'autogen-agentchat==0.4.0.dev6' +pip install 'autogen-agentchat==0.4.0.dev7' ``` -[{fas}`circle-info;pst-color-primary` User Guide](/user-guide/agentchat-user-guide/index.md) | [{fas}`file-code;pst-color-primary` API Reference](/reference/python/autogen_agentchat/autogen_agentchat.rst) | [{fab}`python;pst-color-primary` PyPI](https://pypi.org/project/autogen-agentchat/0.4.0.dev6/) | [{fab}`github;pst-color-primary` Source](https://github.com/microsoft/autogen/tree/main/python/packages/autogen-agentchat) +[{fas}`circle-info;pst-color-primary` User Guide](/user-guide/agentchat-user-guide/index.md) | [{fas}`file-code;pst-color-primary` API Reference](/reference/python/autogen_agentchat/autogen_agentchat.rst) | [{fab}`python;pst-color-primary` PyPI](https://pypi.org/project/autogen-agentchat/0.4.0.dev7/) | [{fab}`github;pst-color-primary` Source](https://github.com/microsoft/autogen/tree/main/python/packages/autogen-agentchat) ::: (pkg-info-autogen-core)= @@ -46,10 +46,10 @@ pip install 'autogen-agentchat==0.4.0.dev6' Implements the core functionality of the AutoGen framework, providing basic building blocks for creating multi-agent systems. ```sh -pip install 'autogen-core==0.4.0.dev6' +pip install 'autogen-core==0.4.0.dev7' ``` -[{fas}`circle-info;pst-color-primary` User Guide](/user-guide/core-user-guide/index.md) | [{fas}`file-code;pst-color-primary` API Reference](/reference/python/autogen_core/autogen_core.rst) | [{fab}`python;pst-color-primary` PyPI](https://pypi.org/project/autogen-core/0.4.0.dev6/) | [{fab}`github;pst-color-primary` Source](https://github.com/microsoft/autogen/tree/main/python/packages/autogen-core) +[{fas}`circle-info;pst-color-primary` User Guide](/user-guide/core-user-guide/index.md) | [{fas}`file-code;pst-color-primary` API Reference](/reference/python/autogen_core/autogen_core.rst) | [{fab}`python;pst-color-primary` PyPI](https://pypi.org/project/autogen-core/0.4.0.dev7/) | [{fab}`github;pst-color-primary` Source](https://github.com/microsoft/autogen/tree/main/python/packages/autogen-core) ::: (pkg-info-autogen-ext)= @@ -61,7 +61,7 @@ pip install 'autogen-core==0.4.0.dev6' Implementations of core components that interface with external services, or use extra dependencies. For example, Docker based code execution. ```sh -pip install 'autogen-ext==0.4.0.dev6' +pip install 'autogen-ext==0.4.0.dev7' ``` Extras: @@ -71,7 +71,7 @@ Extras: - `docker` needed for {py:class}`~autogen_ext.code_executors.DockerCommandLineCodeExecutor` - `openai` needed for {py:class}`~autogen_ext.models.OpenAIChatCompletionClient` -[{fas}`circle-info;pst-color-primary` User Guide](/user-guide/extensions-user-guide/index.md) | [{fas}`file-code;pst-color-primary` API Reference](/reference/python/autogen_ext/autogen_ext.rst) | [{fab}`python;pst-color-primary` PyPI](https://pypi.org/project/autogen-ext/0.4.0.dev6/) | [{fab}`github;pst-color-primary` Source](https://github.com/microsoft/autogen/tree/main/python/packages/autogen-ext) +[{fas}`circle-info;pst-color-primary` User Guide](/user-guide/extensions-user-guide/index.md) | [{fas}`file-code;pst-color-primary` API Reference](/reference/python/autogen_ext/autogen_ext.rst) | [{fab}`python;pst-color-primary` PyPI](https://pypi.org/project/autogen-ext/0.4.0.dev7/) | [{fab}`github;pst-color-primary` Source](https://github.com/microsoft/autogen/tree/main/python/packages/autogen-ext) ::: (pkg-info-autogen-magentic-one)= diff --git a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/installation.md b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/installation.md index ba3826fe2c37..74116bc39fa5 100644 --- a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/installation.md +++ b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/installation.md @@ -61,7 +61,11 @@ Install the `autogen-agentchat` package using pip: ```bash -pip install 'autogen-agentchat==0.4.0.dev6' +pip install 'autogen-agentchat==0.4.0.dev7' +``` + +```{note} +Python 3.10 or later is required. ``` ## Install OpenAI for Model Client @@ -70,7 +74,7 @@ To use the OpenAI and Azure OpenAI models, you need to install the following extensions: ```bash -pip install 'autogen-ext[openai]==0.4.0.dev6' +pip install 'autogen-ext[openai]==0.4.0.dev7' ``` ## Install Docker for Code Execution diff --git a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/quickstart.ipynb b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/quickstart.ipynb index 061284f78004..9e946a47340b 100644 --- a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/quickstart.ipynb +++ b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/quickstart.ipynb @@ -37,7 +37,7 @@ }, "outputs": [], "source": [ - "pip install 'autogen-agentchat==0.4.0.dev6' 'autogen-ext[openai]==0.4.0.dev6'" + "pip install 'autogen-agentchat==0.4.0.dev7' 'autogen-ext[openai]==0.4.0.dev7'" ] }, { diff --git a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/agents.ipynb b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/agents.ipynb index a4c92c8da3a3..9fa0b845fc26 100644 --- a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/agents.ipynb +++ b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/agents.ipynb @@ -191,6 +191,7 @@ "The following preset agents are available:\n", "\n", "- {py:class}`~autogen_agentchat.agents.CodeExecutorAgent`: An agent that can execute code.\n", + "- {py:class}`~autogen_ext.agents.OpenAIAssistantAgent`: An agent that is backed by an OpenAI Assistant, with ability to use custom tools.\n", "- {py:class}`~autogen_ext.agents.MultimodalWebSurfer`: A multi-modal agent that can search the web and visit web pages for information." ] }, diff --git a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/models.ipynb b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/models.ipynb index 2adc862e541b..f44a187609ff 100644 --- a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/models.ipynb +++ b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/models.ipynb @@ -30,7 +30,7 @@ }, "outputs": [], "source": [ - "pip install 'autogen-ext[openai]==0.4.0.dev6'" + "pip install 'autogen-ext[openai]==0.4.0.dev7'" ] }, { @@ -110,7 +110,7 @@ }, "outputs": [], "source": [ - "pip install 'autogen-ext[openai,azure]==0.4.0.dev6'" + "pip install 'autogen-ext[openai,azure]==0.4.0.dev7'" ] }, { @@ -137,16 +137,12 @@ "token_provider = get_bearer_token_provider(DefaultAzureCredential(), \"https://cognitiveservices.azure.com/.default\")\n", "\n", "az_model_client = AzureOpenAIChatCompletionClient(\n", - " model=\"{your-azure-deployment}\",\n", + " azure_deployment=\"{your-azure-deployment}\",\n", + " model=\"{model-name, such as gpt-4o}\",\n", " api_version=\"2024-06-01\",\n", " azure_endpoint=\"https://{your-custom-endpoint}.openai.azure.com/\",\n", " azure_ad_token_provider=token_provider, # Optional if you choose key-based authentication.\n", " # api_key=\"sk-...\", # For key-based authentication.\n", - " model_capabilities={\n", - " \"vision\": True,\n", - " \"function_calling\": True,\n", - " \"json_output\": True,\n", - " },\n", ")" ] }, diff --git a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/selector-group-chat.ipynb b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/selector-group-chat.ipynb index 0a3bb0fe3135..3377d3d47c34 100644 --- a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/selector-group-chat.ipynb +++ b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/selector-group-chat.ipynb @@ -11,46 +11,61 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The `SelectorGroupChat` implements a team coordination pattern where participants take turns publishing messages, with the next speaker selected by a generative model (LLM) based on the conversation context. This enables dynamic and context-aware multi-agent conversations.\n", + "{py:class}`~autogen_agentchat.teams.SelectorGroupChat` implements a team where participants take turns broadcasting messages to all other participants,\n", + "with the next speaker selected by a generative model (e.g., an LLM) based on the shared context. \n", + "This enables dynamic and context-aware multi-agent collaboration.\n", "\n", - "\n", - "`SelectorGroupChat` provides several key features:\n", - "- Dynamic speaker selection using an LLM to analyze conversation context\n", + "{py:class}`~autogen_agentchat.teams.SelectorGroupChat` provides several key features:\n", + "- Model-based speaker selection\n", "- Configurable participant roles and descriptions\n", "- Optional prevention of consecutive turns by the same speaker\n", "- Customizable selection prompting\n", + "- Customizable selection function to override the default model-based selection\n", + "\n", + "```{note}\n", + "{py:class}`~autogen_agentchat.teams.SelectorGroupChat` is a high-level API.\n", + "If you need more control and customization that is not supported by this API,\n", + "you can take a look at the [Group Chat Pattern](../../core-user-guide/design-patterns/group-chat.ipynb)\n", + "in the Core API documentation and implement your own group chat logic.\n", + "```\n", + "\n", + "## How does it work?\n", "\n", + "{py:class}`~autogen_agentchat.teams.SelectorGroupChat` is a group chat similar to {py:class}`~autogen_agentchat.teams.RoundRobinGroupChat`,\n", + "but with a model-based next speaker selection mechanism.\n", + "When the team receives a task through {py:meth}`~autogen_agentchat.teams.BaseGroupChat.run` or {py:meth}`~autogen_agentchat.teams.BaseGroupChat.run_stream`,\n", + "the following steps are executed:\n", "\n", - "### Speaker Selection Process\n", + "1. The team analyzes the current conversation context, including the conversation history and participants' {py:attr}`~autogen_agentchat.base.ChatAgent.name` and {py:attr}`~autogen_agentchat.base.ChatAgent.description` attributes, to determine the next speaker using a model. You can override the model by providing a custom selection function.\n", + "2. The team prompts the selected speaker agent to provide a response, which is then **broadcasted** to all other participants.\n", + "3. The termination condition is checked to determine if the conversation should end, if not, the process repeats from step 1.\n", + "4. When the conversation ends, the team returns the {py:class}`~autogen_agentchat.base.TaskResult` containing the conversation history from this task.\n", "\n", - "The chat uses an LLM to select the next speaker by:\n", - "1. Analyzing the conversation history\n", - "2. Evaluating participant roles and descriptions\n", - "3. Using a configurable prompt template to make the selection\n", - "4. Validating that exactly one participant is selected\n", - "\n" + "Once the team finishes the task, the conversation context is kept within the team and all participants, so the next task can continue from the previous conversation context.\n", + "You can reset the conversation context by calling {py:meth}`~autogen_agentchat.teams.BaseGroupChat.reset`.\n", + "\n", + "In this section, we will demonstrate how to use {py:class}`~autogen_agentchat.teams.SelectorGroupChat` with a simple example for a web search and data analysis task." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Web Search and Analysis Example" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 14, "metadata": {}, "outputs": [], "source": [ - "import asyncio\n", - "from typing import List, Sequence\n", + "from typing import Sequence\n", "\n", - "from autogen_agentchat.agents import (\n", - " BaseChatAgent,\n", - " CodingAssistantAgent,\n", - " ToolUseAssistantAgent,\n", - ")\n", - "from autogen_agentchat.base import Response\n", - "from autogen_agentchat.messages import ChatMessage, StopMessage, TextMessage\n", - "from autogen_agentchat.task import TextMentionTermination\n", + "from autogen_agentchat.agents import AssistantAgent\n", + "from autogen_agentchat.messages import AgentMessage\n", + "from autogen_agentchat.task import Console, MaxMessageTermination, TextMentionTermination\n", "from autogen_agentchat.teams import SelectorGroupChat\n", - "from autogen_core.base import CancellationToken\n", - "from autogen_core.components.tools import FunctionTool\n", "from autogen_ext.models import OpenAIChatCompletionClient" ] }, @@ -58,61 +73,167 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Defining Agents\n", - "The `UserProxyAgent` allows the user to input messages directly. This agent waits for user input and returns a text message or a stop message if the user decides to terminate the conversation." + "### Agents\n", + "\n", + "![Selector Group Chat](selector-group-chat.svg)\n", + "\n", + "This system uses three specialized agents:\n", + "\n", + "- **Planning Agent**: The strategic coordinator that breaks down complex tasks into manageable subtasks. \n", + "- **Web Search Agent**: An information retrieval specialist that interfaces with the `search_web_tool`.\n", + "- **Data Analyst Agent**: An agent specialist in performing calculations equipped with `percentage_change_tool`. " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The tools `search_web_tool` and `percentage_change_tool` are external tools that the agents can use to perform their tasks." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ - "class UserProxyAgent(BaseChatAgent):\n", - " def __init__(self, name: str) -> None:\n", - " super().__init__(name, \"A human user.\")\n", - "\n", - " @property\n", - " def produced_message_types(self) -> List[type[ChatMessage]]:\n", - " return [TextMessage, StopMessage]\n", - "\n", - " async def on_messages(self, messages: Sequence[ChatMessage], cancellation_token: CancellationToken) -> Response:\n", - " user_input = await asyncio.get_event_loop().run_in_executor(None, input, \"Enter your response: \")\n", - " if \"TERMINATE\" in user_input:\n", - " return Response(chat_message=StopMessage(content=\"User has terminated the conversation.\", source=self.name))\n", - " return Response(chat_message=TextMessage(content=user_input, source=self.name))\n", - "\n", - " async def on_reset(self, cancellation_token: CancellationToken) -> None:\n", - " pass" + "# Note: This example uses mock tools instead of real APIs for demonstration purposes\n", + "def search_web_tool(query: str) -> str:\n", + " if \"2006-2007\" in query:\n", + " return \"\"\"Here are the total points scored by Miami Heat players in the 2006-2007 season:\n", + " Udonis Haslem: 844 points\n", + " Dwayne Wade: 1397 points\n", + " James Posey: 550 points\n", + " ...\n", + " \"\"\"\n", + " elif \"2007-2008\" in query:\n", + " return \"The number of total rebounds for Dwayne Wade in the Miami Heat season 2007-2008 is 214.\"\n", + " elif \"2008-2009\" in query:\n", + " return \"The number of total rebounds for Dwayne Wade in the Miami Heat season 2008-2009 is 398.\"\n", + " return \"No data found.\"\n", + "\n", + "\n", + "def percentage_change_tool(start: float, end: float) -> float:\n", + " return ((end - start) / start) * 100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's create the specialized agents using the {py:class}`~autogen_agentchat.agents.AssistantAgent` class.\n", + "It is important to note that the agents' {py:attr}`~autogen_agentchat.base.ChatAgent.name` and {py:attr}`~autogen_agentchat.base.ChatAgent.description` attributes are used by the model to determine the next speaker,\n", + "so it is recommended to provide meaningful names and descriptions." ] }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 11, "metadata": {}, "outputs": [], "source": [ - "async def flight_search(start: str, destination: str, date: str) -> str:\n", - " return \"\\n\".join(\n", - " [\n", - " f\"AC24 from {start} to {destination} on {date} is $500\",\n", - " f\"UA23 from {start} to {destination} on {date} is $450\",\n", - " f\"AL21 from {start} to {destination} on {date} is $400\",\n", - " ]\n", - " )\n", - "\n", - "\n", - "async def flight_booking(flight: str, date: str) -> str:\n", - " return f\"Booked flight {flight} on {date}\"" + "model_client = OpenAIChatCompletionClient(model=\"gpt-4o\")\n", + "\n", + "planning_agent = AssistantAgent(\n", + " \"PlanningAgent\",\n", + " description=\"An agent for planning tasks, this agent should be the first to engage when given a new task.\",\n", + " model_client=model_client,\n", + " system_message=\"\"\"\n", + " You are a planning agent.\n", + " Your job is to break down complex tasks into smaller, manageable subtasks.\n", + " Your team members are:\n", + " Web search agent: Searches for information\n", + " Data analyst: Performs calculations\n", + "\n", + " You only plan and delegate tasks - you do not execute them yourself.\n", + "\n", + " When assigning tasks, use this format:\n", + " 1. : \n", + "\n", + " After all tasks are complete, summarize the findings and end with \"TERMINATE\".\n", + " \"\"\",\n", + ")\n", + "\n", + "web_search_agent = AssistantAgent(\n", + " \"WebSearchAgent\",\n", + " description=\"A web search agent.\",\n", + " tools=[search_web_tool],\n", + " model_client=model_client,\n", + " system_message=\"\"\"\n", + " You are a web search agent.\n", + " Your only tool is search_tool - use it to find information.\n", + " You make only one search call at a time.\n", + " Once you have the results, you never do calculations based on them.\n", + " \"\"\",\n", + ")\n", + "\n", + "data_analyst_agent = AssistantAgent(\n", + " \"DataAnalystAgent\",\n", + " description=\"A data analyst agent. Useful for performing calculations.\",\n", + " model_client=model_client,\n", + " tools=[percentage_change_tool],\n", + " system_message=\"\"\"\n", + " You are a data analyst.\n", + " Given the tasks you have been assigned, you should analyze the data and provide results using the tools provided.\n", + " \"\"\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Workflow\n", + "\n", + "1. The task is received by the {py:class}`~autogen_agentchat.teams.SelectorGroupChat` which, based on agent descriptions, selects the most appropriate agent to handle the initial task (typically the Planning Agent).\n", + "\n", + "2. The **Planning Agent** analyzes the task and breaks it down into subtasks, assigning each to the most appropriate agent using the format:\n", + " ` : `\n", + "\n", + "3. Based on the conversation context and agent descriptions, the {py:class}`~autogen_agent.teams.SelectorGroupChat` manager dynamically selects the next agent to handle their assigned subtask.\n", + "\n", + "4. The **Web Search Agent** performs searches one at a time, storing results in the shared conversation history.\n", + "\n", + "5. The **Data Analyst** processes the gathered information using available calculation tools when selected.\n", + "\n", + "6. The workflow continues with agents being dynamically selected until either:\n", + " - The Planning Agent determines all subtasks are complete and sends \"TERMINATE\"\n", + " - An alternative termination condition is met (e.g., a maximum number of messages)\n", + "\n", + "When defining your agents, make sure to include a helpful {py:attr}`~autogen_agentchat.base.ChatAgent.description` since this is used to decide which agent to select next." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "The `ToolUseAssistantAgent` is responsible for calling external tools. In this example, two tools are defined: `flight_search` and `flight_booking`.\n", + "Let's create the team with two termination conditions:\n", + "{py:class}`~autogen_agentchat.task.TextMentionTermination` to end the conversation when the Planning Agent sends \"TERMINATE\",\n", + "and {py:class}`~autogen_agentchat.task.MaxMessageTermination` to limit the conversation to 25 messages to avoid infinite loop." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "text_mention_termination = TextMentionTermination(\"TERMINATE\")\n", + "max_messages_termination = MaxMessageTermination(max_messages=25)\n", + "termination = text_mention_termination | max_messages_termination\n", "\n", - "Additionally, the `CodingAssistantAgent` serves as a general travel assistant with predefined behavior specified in the `system_message`." + "team = SelectorGroupChat(\n", + " [planning_agent, web_search_agent, data_analyst_agent],\n", + " model_client=OpenAIChatCompletionClient(model=\"gpt-4o-mini\"),\n", + " termination_condition=termination,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now we run the team with a task to find information about an NBA player." ] }, { @@ -124,144 +245,200 @@ "name": "stdout", "output_type": "stream", "text": [ + "---------- user ----------\n", + "Who was the Miami Heat player with the highest points in the 2006-2007 season, and what was the percentage change in his total rebounds between the 2007-2008 and 2008-2009 seasons?\n", + "---------- PlanningAgent ----------\n", + "To answer your question, we need to separate this task into several subtasks:\n", "\n", - "--------------------------------------------------------------------------- \n", - "\u001b[91m[2024-10-08T20:35:30.283450]:\u001b[0m\n", + "1. Web search agent: Find out who was the Miami Heat player with the highest points in the 2006-2007 NBA season.\n", + "2. Web search agent: Find the total rebounds for that player in the 2007-2008 NBA season.\n", + "3. Web search agent: Find the total rebounds for that player in the 2008-2009 NBA season.\n", + "4. Data analyst: Calculate the percentage change in the player's total rebounds between the 2007-2008 and 2008-2009 seasons.\n", "\n", - "Help user plan a trip and book a flight." + "Let's start with these tasks.\n", + "[Prompt tokens: 159, Completion tokens: 130]\n", + "---------- WebSearchAgent ----------\n", + "[FunctionCall(id='call_js7ogBp0UDmHfvLo6BmWFpM1', arguments='{\"query\":\"Miami Heat player highest points 2006-2007 season\"}', name='search_web_tool')]\n", + "[Prompt tokens: 279, Completion tokens: 26]\n", + "---------- WebSearchAgent ----------\n", + "[FunctionExecutionResult(content='Here are the total points scored by Miami Heat players in the 2006-2007 season:\\n Udonis Haslem: 844 points\\n Dwayne Wade: 1397 points\\n James Posey: 550 points\\n ...\\n ', call_id='call_js7ogBp0UDmHfvLo6BmWFpM1')]\n", + "---------- WebSearchAgent ----------\n", + "[FunctionCall(id='call_G7ATvIq0rSjc8fqLdKQ5uWI4', arguments='{\"query\": \"Dwyane Wade total rebounds 2007-2008 season\"}', name='search_web_tool'), FunctionCall(id='call_Xzw9bAvgfo40EjILophG5pnl', arguments='{\"query\": \"Dwyane Wade total rebounds 2008-2009 season\"}', name='search_web_tool')]\n", + "[Prompt tokens: 371, Completion tokens: 70]\n", + "---------- WebSearchAgent ----------\n", + "[FunctionExecutionResult(content='The number of total rebounds for Dwayne Wade in the Miami Heat season 2007-2008 is 214.', call_id='call_G7ATvIq0rSjc8fqLdKQ5uWI4'), FunctionExecutionResult(content='The number of total rebounds for Dwayne Wade in the Miami Heat season 2008-2009 is 398.', call_id='call_Xzw9bAvgfo40EjILophG5pnl')]\n", + "---------- WebSearchAgent ----------\n", + "The Miami Heat player with the highest points in the 2006-2007 season was Dwyane Wade, with 1,397 points.\n", + "\n", + "Between the 2007-2008 and 2008-2009 seasons, Dwyane Wade's total rebounds increased from 214 to 398. To calculate the percentage change:\n", + "\n", + "Percentage Change = \\(\\frac{(398 - 214)}{214} \\times 100\\)\n", + "\n", + "Now, a data analyst would calculate the actual percentage change based on these numbers.\n", + "[Prompt tokens: 506, Completion tokens: 107]\n", + "---------- DataAnalystAgent ----------\n", + "[FunctionCall(id='call_76VkQ2nnKrwtuI1dmjLQ7G5P', arguments='{\"start\":214,\"end\":398}', name='percentage_change_tool')]\n", + "[Prompt tokens: 383, Completion tokens: 20]\n", + "---------- DataAnalystAgent ----------\n", + "[FunctionExecutionResult(content='85.98130841121495', call_id='call_76VkQ2nnKrwtuI1dmjLQ7G5P')]\n", + "---------- DataAnalystAgent ----------\n", + "The Miami Heat player with the highest points in the 2006-2007 season was Dwyane Wade. His total rebounds increased by approximately 85.98% between the 2007-2008 and 2008-2009 seasons.\n", + "[Prompt tokens: 424, Completion tokens: 52]\n", + "---------- PlanningAgent ----------\n", + "The Miami Heat player with the highest points in the 2006-2007 season was Dwyane Wade, scoring 1,397 points. Between the 2007-2008 and 2008-2009 seasons, Dwyane Wade's total rebounds increased by approximately 85.98%. \n", + "\n", + "TERMINATE\n", + "[Prompt tokens: 470, Completion tokens: 66]\n", + "---------- Summary ----------\n", + "Number of messages: 11\n", + "Finish reason: Text 'TERMINATE' mentioned\n", + "Total prompt tokens: 2592\n", + "Total completion tokens: 471\n", + "Duration: 11.95 seconds\n" ] }, + { + "data": { + "text/plain": [ + "TaskResult(messages=[TextMessage(source='user', models_usage=None, content='Who was the Miami Heat player with the highest points in the 2006-2007 season, and what was the percentage change in his total rebounds between the 2007-2008 and 2008-2009 seasons?'), TextMessage(source='PlanningAgent', models_usage=RequestUsage(prompt_tokens=159, completion_tokens=130), content=\"To answer your question, we need to separate this task into several subtasks:\\n\\n1. Web search agent: Find out who was the Miami Heat player with the highest points in the 2006-2007 NBA season.\\n2. Web search agent: Find the total rebounds for that player in the 2007-2008 NBA season.\\n3. Web search agent: Find the total rebounds for that player in the 2008-2009 NBA season.\\n4. Data analyst: Calculate the percentage change in the player's total rebounds between the 2007-2008 and 2008-2009 seasons.\\n\\nLet's start with these tasks.\"), ToolCallMessage(source='WebSearchAgent', models_usage=RequestUsage(prompt_tokens=279, completion_tokens=26), content=[FunctionCall(id='call_js7ogBp0UDmHfvLo6BmWFpM1', arguments='{\"query\":\"Miami Heat player highest points 2006-2007 season\"}', name='search_web_tool')]), ToolCallResultMessage(source='WebSearchAgent', models_usage=None, content=[FunctionExecutionResult(content='Here are the total points scored by Miami Heat players in the 2006-2007 season:\\n Udonis Haslem: 844 points\\n Dwayne Wade: 1397 points\\n James Posey: 550 points\\n ...\\n ', call_id='call_js7ogBp0UDmHfvLo6BmWFpM1')]), ToolCallMessage(source='WebSearchAgent', models_usage=RequestUsage(prompt_tokens=371, completion_tokens=70), content=[FunctionCall(id='call_G7ATvIq0rSjc8fqLdKQ5uWI4', arguments='{\"query\": \"Dwyane Wade total rebounds 2007-2008 season\"}', name='search_web_tool'), FunctionCall(id='call_Xzw9bAvgfo40EjILophG5pnl', arguments='{\"query\": \"Dwyane Wade total rebounds 2008-2009 season\"}', name='search_web_tool')]), ToolCallResultMessage(source='WebSearchAgent', models_usage=None, content=[FunctionExecutionResult(content='The number of total rebounds for Dwayne Wade in the Miami Heat season 2007-2008 is 214.', call_id='call_G7ATvIq0rSjc8fqLdKQ5uWI4'), FunctionExecutionResult(content='The number of total rebounds for Dwayne Wade in the Miami Heat season 2008-2009 is 398.', call_id='call_Xzw9bAvgfo40EjILophG5pnl')]), TextMessage(source='WebSearchAgent', models_usage=RequestUsage(prompt_tokens=506, completion_tokens=107), content=\"The Miami Heat player with the highest points in the 2006-2007 season was Dwyane Wade, with 1,397 points.\\n\\nBetween the 2007-2008 and 2008-2009 seasons, Dwyane Wade's total rebounds increased from 214 to 398. To calculate the percentage change:\\n\\nPercentage Change = \\\\(\\\\frac{(398 - 214)}{214} \\\\times 100\\\\)\\n\\nNow, a data analyst would calculate the actual percentage change based on these numbers.\"), ToolCallMessage(source='DataAnalystAgent', models_usage=RequestUsage(prompt_tokens=383, completion_tokens=20), content=[FunctionCall(id='call_76VkQ2nnKrwtuI1dmjLQ7G5P', arguments='{\"start\":214,\"end\":398}', name='percentage_change_tool')]), ToolCallResultMessage(source='DataAnalystAgent', models_usage=None, content=[FunctionExecutionResult(content='85.98130841121495', call_id='call_76VkQ2nnKrwtuI1dmjLQ7G5P')]), TextMessage(source='DataAnalystAgent', models_usage=RequestUsage(prompt_tokens=424, completion_tokens=52), content='The Miami Heat player with the highest points in the 2006-2007 season was Dwyane Wade. His total rebounds increased by approximately 85.98% between the 2007-2008 and 2008-2009 seasons.'), TextMessage(source='PlanningAgent', models_usage=RequestUsage(prompt_tokens=470, completion_tokens=66), content=\"The Miami Heat player with the highest points in the 2006-2007 season was Dwyane Wade, scoring 1,397 points. Between the 2007-2008 and 2008-2009 seasons, Dwyane Wade's total rebounds increased by approximately 85.98%. \\n\\nTERMINATE\")], stop_reason=\"Text 'TERMINATE' mentioned\")" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "task = \"Who was the Miami Heat player with the highest points in the 2006-2007 season, and what was the percentage change in his total rebounds between the 2007-2008 and 2008-2009 seasons?\"\n", + "\n", + "# Use asyncio.run(...) if you are running this in a script.\n", + "await Console(team.run_stream(task=task))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As we can see, after the Web Search Agent conducts the necessary searches and the Data Analyst Agent completes the necessary calculations, we find that Dwayne Wade was the Miami Heat player with the highest points in the 2006-2007 season, and the percentage change in his total rebounds between the 2007-2008 and 2008-2009 seasons is 85.98%!" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Custom Selector Function" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Often times we want better control over the selection process. \n", + "To this end, we can set the `selector_func` argument with a custom selector function to override the default model-based selection.\n", + "For instance, we want the Planning Agent to speak immediately after any specialized agent to check the progress.\n", + "\n", + "```{note}\n", + "Returning `None` from the custom selector function will use the default model-based selection.\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ + "---------- user ----------\n", + "Who was the Miami Heat player with the highest points in the 2006-2007 season, and what was the percentage change in his total rebounds between the 2007-2008 and 2008-2009 seasons?\n", + "---------- PlanningAgent ----------\n", + "To solve this inquiry, let's break it down into smaller tasks again:\n", "\n", - "--------------------------------------------------------------------------- \n", - "\u001b[91m[2024-10-08T20:35:48.275743], User:\u001b[0m\n", - "\n", - "\n", - "--------------------------------------------------------------------------- \n", - "\u001b[91m[2024-10-08T20:35:50.795496], TravelAssistant:\u001b[0m\n", - "\n", - "I'd be happy to help you plan your trip! To get started, could you please provide me with the following details:\n", - "\n", - "1. Your departure city and the destination city.\n", - "2. Your travel dates (departure and return).\n", - "3. The number of travelers and their ages (if any children are involved).\n", - "4. Your budget for flights and accommodations, if you have one in mind.\n", - "5. Any specific activities or attractions you're interested in at the destination.\n", - "\n", - "Once I have this information, I can help you find the best options!\n", - "--------------------------------------------------------------------------- \n", - "\u001b[91m[2024-10-08T20:35:59.701486], User:\u001b[0m\n", - "\n", - "Traveling to toronto from new york\n", - "--------------------------------------------------------------------------- \n", - "\u001b[91m[2024-10-08T20:36:02.325330], TravelAssistant:\u001b[0m\n", - "\n", - "Great choice! Toronto is a vibrant city with a lot to offer. Now, could you please provide the following additional details to help me assist you better?\n", - "\n", - "1. What are your travel dates (departure and return)?\n", - "2. How many travelers will be going, and what are their ages?\n", - "3. Do you have a budget for the flight and accommodations?\n", - "4. Are there any specific activities or attractions you’re interested in while in Toronto?\n", - "\n", - "Once I have this information, I can help you find the best flights and suggestions for your trip!\n", - "--------------------------------------------------------------------------- \n", - "\u001b[91m[2024-10-08T20:36:20.633004], User:\u001b[0m\n", - "\n", - "leaving on december 7 and returning on 12\n", - "--------------------------------------------------------------------------- \n", - "\u001b[91m[2024-10-08T20:36:23.202871], TravelAssistant:\u001b[0m\n", - "\n", - "Thank you for the details! Here's what I have so far:\n", - "\n", - "- **Departure City:** New York\n", - "- **Destination City:** Toronto\n", - "- **Departure Date:** December 7\n", - "- **Return Date:** December 12\n", + "1. Find out who was the Miami Heat player with the highest points in the 2006-2007 NBA season.\n", + "2. Find that player's total rebounds for the 2007-2008 NBA season.\n", + "3. Find that player's total rebounds for the 2008-2009 NBA season.\n", + "4. Calculate the percentage change in the player's total rebounds from the 2007-2008 to the 2008-2009 season.\n", "\n", - "Now, could you please provide:\n", + "Let's proceed with these tasks and find the necessary information.\n", + "[Prompt tokens: 595, Completion tokens: 115]\n", + "---------- WebSearchAgent ----------\n", + "The Miami Heat player with the highest points in the 2006-2007 season was Dwyane Wade, who scored a total of 1,397 points.\n", "\n", - "1. The number of travelers and their ages.\n", - "2. Your budget for flights and accommodations (if applicable).\n", - "3. Any specific activities or attractions you're interested in while in Toronto.\n", + "In terms of his rebound statistics:\n", + "- In the 2007-2008 season, Dwyane Wade recorded 214 total rebounds.\n", + "- In the 2008-2009 season, he recorded 398 total rebounds.\n", "\n", - "This will help me provide more tailored options for your trip!\n", - "--------------------------------------------------------------------------- \n", - "\u001b[91m[2024-10-08T20:36:38.096554], User:\u001b[0m\n", + "To find the percentage change in his total rebounds, a data analyst would perform the following calculation:\n", "\n", - "just myself one adult\n", - "--------------------------------------------------------------------------- \n", - "\u001b[91m[2024-10-08T20:36:40.307824], FlightBroker:\u001b[0m\n", + "\\[\n", + "\\text{Percentage Change} = \\left( \\frac{398 - 214}{214} \\right) \\times 100\n", + "\\]\n", "\n", - "Thanks for the information! Here's what I have:\n", + "A data analyst would use the above numbers to determine the percentage change in his total rebounds between these two seasons.\n", + "[Prompt tokens: 794, Completion tokens: 154]\n", + "---------- PlanningAgent ----------\n", + "The Miami Heat player with the highest points in the 2006-2007 season was Dwyane Wade, with a total of 1,397 points. His total rebounds increased from 214 in the 2007-2008 season to 398 in the 2008-2009 season.\n", "\n", - "- **Departure City:** New York\n", - "- **Destination City:** Toronto\n", - "- **Departure Date:** December 7\n", - "- **Return Date:** December 12\n", - "- **Number of Travelers:** 1 Adult\n", + "Let's have a data analyst calculate the percentage change: \n", "\n", - "Could you let me know if you have a budget for flights and accommodations? Additionally, are there any specific activities or attractions you're interested in while in Toronto? This will help me provide the best options for your trip!\n", - "--------------------------------------------------------------------------- \n", - "\u001b[91m[2024-10-08T20:36:45.875280], User:\u001b[0m\n", + "1. Data analyst: Calculate the percentage change in Dwyane Wade's total rebounds between the 2007-2008 and 2008-2009 seasons using the formula provided by the Web search agent.\n", + "[Prompt tokens: 878, Completion tokens: 116]\n", + "---------- DataAnalystAgent ----------\n", + "[FunctionCall(id='call_Fh84DXp5MxFzutmKVvclw5Cz', arguments='{\"start\":214,\"end\":398}', name='percentage_change_tool')]\n", + "[Prompt tokens: 942, Completion tokens: 20]\n", + "---------- DataAnalystAgent ----------\n", + "[FunctionExecutionResult(content='85.98130841121495', call_id='call_Fh84DXp5MxFzutmKVvclw5Cz')]\n", + "---------- DataAnalystAgent ----------\n", + "The Miami Heat player with the highest points in the 2006-2007 season was Dwyane Wade. The percentage change in his total rebounds between the 2007-2008 season and the 2008-2009 season was approximately 85.98%.\n", + "[Prompt tokens: 983, Completion tokens: 56]\n", + "---------- PlanningAgent ----------\n", + "The Miami Heat player with the highest points in the 2006-2007 season was Dwyane Wade, with a total of 1,397 points. Between the 2007-2008 and 2008-2009 seasons, his total rebounds increased by approximately 85.98%. \n", "\n", - "that's it\n", - "--------------------------------------------------------------------------- \n", - "\u001b[91m[2024-10-08T20:36:50.925624], FlightBroker:\u001b[0m\n", - "\n", - "Your flights have been successfully booked! Here are the details:\n", - "\n", - "- **Departure:** New York to Toronto\n", - " - **Flight:** AL21\n", - " - **Date:** December 7, 2023\n", - "\n", - "- **Return:** Toronto to New York\n", - " - **Flight:** AL21\n", - " - **Date:** December 12, 2023\n", - "\n", - "If you need help with accommodations, activities, or anything else for your trip, feel free to let me know! \n", - "\n", - "TERMINATE" + "TERMINATE\n", + "[Prompt tokens: 1065, Completion tokens: 65]\n", + "---------- Summary ----------\n", + "Number of messages: 8\n", + "Finish reason: Text 'TERMINATE' mentioned\n", + "Total prompt tokens: 5257\n", + "Total completion tokens: 526\n", + "Duration: 11.98 seconds\n" ] }, { "data": { "text/plain": [ - "TeamRunResult(messages=[TextMessage(source='user', content='Help user plan a trip and book a flight.'), TextMessage(source='User', content=''), TextMessage(source='TravelAssistant', content=\"I'd be happy to help you plan your trip! To get started, could you please provide me with the following details:\\n\\n1. Your departure city and the destination city.\\n2. Your travel dates (departure and return).\\n3. The number of travelers and their ages (if any children are involved).\\n4. Your budget for flights and accommodations, if you have one in mind.\\n5. Any specific activities or attractions you're interested in at the destination.\\n\\nOnce I have this information, I can help you find the best options!\"), TextMessage(source='User', content='Traveling to toronto from new york'), TextMessage(source='TravelAssistant', content='Great choice! Toronto is a vibrant city with a lot to offer. Now, could you please provide the following additional details to help me assist you better?\\n\\n1. What are your travel dates (departure and return)?\\n2. How many travelers will be going, and what are their ages?\\n3. Do you have a budget for the flight and accommodations?\\n4. Are there any specific activities or attractions you’re interested in while in Toronto?\\n\\nOnce I have this information, I can help you find the best flights and suggestions for your trip!'), TextMessage(source='User', content='leaving on december 7 and returning on 12'), TextMessage(source='TravelAssistant', content=\"Thank you for the details! Here's what I have so far:\\n\\n- **Departure City:** New York\\n- **Destination City:** Toronto\\n- **Departure Date:** December 7\\n- **Return Date:** December 12\\n\\nNow, could you please provide:\\n\\n1. The number of travelers and their ages.\\n2. Your budget for flights and accommodations (if applicable).\\n3. Any specific activities or attractions you're interested in while in Toronto.\\n\\nThis will help me provide more tailored options for your trip!\"), TextMessage(source='User', content='just myself one adult'), TextMessage(source='FlightBroker', content=\"Thanks for the information! Here's what I have:\\n\\n- **Departure City:** New York\\n- **Destination City:** Toronto\\n- **Departure Date:** December 7\\n- **Return Date:** December 12\\n- **Number of Travelers:** 1 Adult\\n\\nCould you let me know if you have a budget for flights and accommodations? Additionally, are there any specific activities or attractions you're interested in while in Toronto? This will help me provide the best options for your trip!\"), TextMessage(source='User', content=\"that's it\"), StopMessage(source='FlightBroker', content='Your flights have been successfully booked! Here are the details:\\n\\n- **Departure:** New York to Toronto\\n - **Flight:** AL21\\n - **Date:** December 7, 2023\\n\\n- **Return:** Toronto to New York\\n - **Flight:** AL21\\n - **Date:** December 12, 2023\\n\\nIf you need help with accommodations, activities, or anything else for your trip, feel free to let me know! \\n\\nTERMINATE'), StopMessage(source='StopMessageTermination', content='Stop message received')])" + "TaskResult(messages=[TextMessage(source='user', models_usage=None, content='Who was the Miami Heat player with the highest points in the 2006-2007 season, and what was the percentage change in his total rebounds between the 2007-2008 and 2008-2009 seasons?'), TextMessage(source='PlanningAgent', models_usage=RequestUsage(prompt_tokens=595, completion_tokens=115), content=\"To solve this inquiry, let's break it down into smaller tasks again:\\n\\n1. Find out who was the Miami Heat player with the highest points in the 2006-2007 NBA season.\\n2. Find that player's total rebounds for the 2007-2008 NBA season.\\n3. Find that player's total rebounds for the 2008-2009 NBA season.\\n4. Calculate the percentage change in the player's total rebounds from the 2007-2008 to the 2008-2009 season.\\n\\nLet's proceed with these tasks and find the necessary information.\"), TextMessage(source='WebSearchAgent', models_usage=RequestUsage(prompt_tokens=794, completion_tokens=154), content='The Miami Heat player with the highest points in the 2006-2007 season was Dwyane Wade, who scored a total of 1,397 points.\\n\\nIn terms of his rebound statistics:\\n- In the 2007-2008 season, Dwyane Wade recorded 214 total rebounds.\\n- In the 2008-2009 season, he recorded 398 total rebounds.\\n\\nTo find the percentage change in his total rebounds, a data analyst would perform the following calculation:\\n\\n\\\\[\\n\\\\text{Percentage Change} = \\\\left( \\\\frac{398 - 214}{214} \\\\right) \\\\times 100\\n\\\\]\\n\\nA data analyst would use the above numbers to determine the percentage change in his total rebounds between these two seasons.'), TextMessage(source='PlanningAgent', models_usage=RequestUsage(prompt_tokens=878, completion_tokens=116), content=\"The Miami Heat player with the highest points in the 2006-2007 season was Dwyane Wade, with a total of 1,397 points. His total rebounds increased from 214 in the 2007-2008 season to 398 in the 2008-2009 season.\\n\\nLet's have a data analyst calculate the percentage change: \\n\\n1. Data analyst: Calculate the percentage change in Dwyane Wade's total rebounds between the 2007-2008 and 2008-2009 seasons using the formula provided by the Web search agent.\"), ToolCallMessage(source='DataAnalystAgent', models_usage=RequestUsage(prompt_tokens=942, completion_tokens=20), content=[FunctionCall(id='call_Fh84DXp5MxFzutmKVvclw5Cz', arguments='{\"start\":214,\"end\":398}', name='percentage_change_tool')]), ToolCallResultMessage(source='DataAnalystAgent', models_usage=None, content=[FunctionExecutionResult(content='85.98130841121495', call_id='call_Fh84DXp5MxFzutmKVvclw5Cz')]), TextMessage(source='DataAnalystAgent', models_usage=RequestUsage(prompt_tokens=983, completion_tokens=56), content='The Miami Heat player with the highest points in the 2006-2007 season was Dwyane Wade. The percentage change in his total rebounds between the 2007-2008 season and the 2008-2009 season was approximately 85.98%.'), TextMessage(source='PlanningAgent', models_usage=RequestUsage(prompt_tokens=1065, completion_tokens=65), content='The Miami Heat player with the highest points in the 2006-2007 season was Dwyane Wade, with a total of 1,397 points. Between the 2007-2008 and 2008-2009 seasons, his total rebounds increased by approximately 85.98%. \\n\\nTERMINATE')], stop_reason=\"Text 'TERMINATE' mentioned\")" ] }, - "execution_count": 4, + "execution_count": 13, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "user_proxy = UserProxyAgent(\"User\")\n", - "flight_broker = ToolUseAssistantAgent(\n", - " \"FlightBroker\",\n", - " description=\"An assistant for booking flights\",\n", - " model_client=OpenAIChatCompletionClient(model=\"gpt-4o-mini\"),\n", - " registered_tools=[\n", - " FunctionTool(flight_search, description=\"Search for flights\"),\n", - " FunctionTool(flight_booking, description=\"Book a flight\"),\n", - " ],\n", - ")\n", - "travel_assistant = CodingAssistantAgent(\n", - " \"TravelAssistant\",\n", - " description=\"A travel assistant\",\n", - " model_client=OpenAIChatCompletionClient(model=\"gpt-4o-mini\"),\n", - " system_message=\"You are a travel assistant.\",\n", - ")\n", + "def selector_func(messages: Sequence[AgentMessage]) -> str | None:\n", + " if messages[-1].source != planning_agent.name:\n", + " return planning_agent.name\n", + " return None\n", + "\n", "\n", - "termination = TextMentionTermination(\"TERMINATE\")\n", "team = SelectorGroupChat(\n", - " [user_proxy, flight_broker, travel_assistant],\n", + " [planning_agent, web_search_agent, data_analyst_agent],\n", " model_client=OpenAIChatCompletionClient(model=\"gpt-4o-mini\"),\n", " termination_condition=termination,\n", + " selector_func=selector_func,\n", ")\n", - "await team.run(task=\"Help user plan a trip and book a flight.\")" + "\n", + "await Console(team.run_stream(task=task))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can see from the conversation log that the Planning Agent always speaks immediately after the specialized agents." ] } ], @@ -281,7 +458,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.6" + "version": "3.12.7" } }, "nbformat": 4, diff --git a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/selector-group-chat.svg b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/selector-group-chat.svg new file mode 100644 index 000000000000..4a4009992c4f --- /dev/null +++ b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/selector-group-chat.svg @@ -0,0 +1,3 @@ + + +
Selector
Selector
Web Search Agent
Web Search Agent
Planning Agent
Planning Agent
Data Analyst
Agent
Data Analyst...
SelectorGroupChat
SelectorGroupChat
Application/User
Application/User
Task
Task
TaskResult
TaskResult
\ No newline at end of file diff --git a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/swarm.ipynb b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/swarm.ipynb index f6b93a0abc3f..68e77690d45b 100644 --- a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/swarm.ipynb +++ b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/swarm.ipynb @@ -4,13 +4,550 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Swarm" + "# Swarm\n", + "\n", + "{py:class}`~autogen_agentchat.teams.Swarm` implements a team in which agents can hand off \n", + "task to other agents based on their capabilities. \n", + "It is a multi-agent design pattern first introduced by OpenAI in \n", + "[an experimental project](https://github.com/openai/swarm).\n", + "The key idea is to let agent delegate tasks to other agents using a special tool call, while\n", + "all agents share the same message context.\n", + "This enables agents to make local decisions about task planning, rather than\n", + "relying on a central orchestrator such as in {py:class}`~autogen_agentchat.teams.SelectorGroupChat`.\n", + "\n", + "```{note}\n", + "{py:class}`~autogen_agentchat.teams.Swarm` is a high-level API. If you need more\n", + "control and customization that is not supported by this API, you can take a look\n", + "at the [Handoff Pattern](../../core-user-guide/design-patterns/handoffs.ipynb)\n", + "in the Core API documentation and implement your own version of the Swarm pattern.\n", + "```\n", + "\n", + "## How Does It Work?\n", + "\n", + "At its core, the {py:class}`~autogen_agentchat.teams.Swarm` team is a group chat\n", + "where agents take turn to generate a response. \n", + "Similar to {py:class}`~autogen_agentchat.teams.SelectorGroupChat`\n", + "and {py:class}`~autogen_agentchat.teams.RoundRobinGroupChat`, participant agents\n", + "broadcast their responses so all agents share the same mesasge context.\n", + "\n", + "Different from the other two group chat teams, at each turn,\n", + "**the speaker agent is selected based on the most recent\n", + "{py:class}`~autogen_agentchat.messages.HandoffMessage` message in the context.**\n", + "This naturally requires each agent in the team to be able to generate\n", + "{py:class}`~autogen_agentchat.messages.HandoffMessage` to signal\n", + "which other agents that it hands off to.\n", + "\n", + "For {py:class}`~autogen_agentchat.agents.AssistantAgent`, you can set the\n", + "`handoffs` argument to specify which agents it can hand off to. You can\n", + "use {py:class}`~autogen_agentchat.agents.Handoff` to customize the message\n", + "content and handoff behavior.\n", + "\n", + "The overall process can be summarized as follows:\n", + "\n", + "1. Each agent has the ability to generate {py:class}`~autogen_agentchat.messages.HandoffMessage`\n", + " to signal which other agents it can hand off to. For {py:class}`~autogen_agentchat.agents.AssistantAgent`, this means setting the `handoffs` argument.\n", + "2. When the team starts on a task, the first speaker agents operate on the task and make locallized decision about whether to hand off and to whom.\n", + "3. When an agent generates a {py:class}`~autogen_agentchat.messages.HandoffMessage`, the receiving agent takes over the task with the same message context.\n", + "4. The process continues until a termination condition is met.\n", + "\n", + "In this section, we will show you two examples of how to use the {py:class}`~autogen_agentchat.teams.Swarm` team:\n", + "\n", + "1. A customer support team with human-in-the-loop handoff.\n", + "2. An automonous team for content generation." ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Customer Support Example" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "![Customer Support](swarm_customer_support.svg)\n", + "\n", + "This system implements a flights refund scenario with two agents:\n", + "\n", + "- **Travel Agent**: Handles general travel and refund coordination.\n", + "- **Flights Refunder**: Specializes in processing flight refunds with the `refund_flight` tool.\n", + "\n", + "Additionally, we let the user interact with the agents, when agents handoff to `\"user\"`.\n", + "\n", + "#### Workflow\n", + "1. The **Travel Agent** initiates the conversation and evaluates the user's request.\n", + "2. Based on the request:\n", + " - For refund-related tasks, the Travel Agent hands off to the **Flights Refunder**.\n", + " - For information needed from the customer, either agent can hand off to the `\"user\"`.\n", + "3. The **Flights Refunder** processes refunds using the `refund_flight` tool when appropriate.\n", + "4. If an agent hands off to the `\"user\"`, the team execution will stop and wait for the user to input a response.\n", + "5. When the user provides input, it's sent back to the team as a {py:class}`~autogen_agentchat.messages.HandaffMessage`. This message is directed to the agent that originally requested user input.\n", + "6. The process continues until the Travel Agent determines the task is complete and terminates the workflow." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Any, Dict, List\n", + "\n", + "from autogen_agentchat.agents import AssistantAgent\n", + "from autogen_agentchat.messages import HandoffMessage\n", + "from autogen_agentchat.task import Console, HandoffTermination, TextMentionTermination\n", + "from autogen_agentchat.teams import Swarm\n", + "from autogen_ext.models import OpenAIChatCompletionClient" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Tools" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "def refund_flight(flight_id: str) -> str:\n", + " \"\"\"Refund a flight\"\"\"\n", + " return f\"Flight {flight_id} refunded\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Agents" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "model_client = OpenAIChatCompletionClient(\n", + " model=\"gpt-4o\",\n", + " # api_key=\"YOUR_API_KEY\",\n", + ")\n", + "\n", + "travel_agent = AssistantAgent(\n", + " \"travel_agent\",\n", + " model_client=model_client,\n", + " handoffs=[\"flights_refunder\", \"user\"],\n", + " system_message=\"\"\"You are a travel agent.\n", + " The flights_refunder is in charge of refunding flights.\n", + " If you need information from the user, you must first send your message, then you can handoff to the user.\n", + " Use TERMINATE when the travel planning is complete.\"\"\",\n", + ")\n", + "\n", + "flights_refunder = AssistantAgent(\n", + " \"flights_refunder\",\n", + " model_client=model_client,\n", + " handoffs=[\"travel_agent\", \"user\"],\n", + " tools=[refund_flight],\n", + " system_message=\"\"\"You are an agent specialized in refunding flights.\n", + " You only need flight reference numbers to refund a flight.\n", + " You have the ability to refund a flight using the refund_flight tool.\n", + " If you need information from the user, you must first send your message, then you can handoff to the user.\n", + " When the transaction is complete, handoff to the travel agent to finalize.\"\"\",\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "termination = HandoffTermination(target=\"user\") | TextMentionTermination(\"TERMINATE\")\n", + "team = Swarm([travel_agent, flights_refunder], termination_condition=termination)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---------- user ----------\n", + "I need to refund my flight.\n", + "---------- travel_agent ----------\n", + "[FunctionCall(id='call_epnozsBbe9i4swPaaBIR4Enl', arguments='{}', name='transfer_to_flights_refunder')]\n", + "[Prompt tokens: 327, Completion tokens: 14]\n", + "---------- travel_agent ----------\n", + "[FunctionExecutionResult(content='Transferred to flights_refunder, adopting the role of flights_refunder immediately.', call_id='call_epnozsBbe9i4swPaaBIR4Enl')]\n", + "---------- travel_agent ----------\n", + "Transferred to flights_refunder, adopting the role of flights_refunder immediately.\n", + "---------- flights_refunder ----------\n", + "I can help you with that. Could you please provide me with your flight reference number so I can process the refund?\n", + "[Prompt tokens: 450, Completion tokens: 25]\n", + "---------- flights_refunder ----------\n", + "[FunctionCall(id='call_giMQVbQ7mXahC5G3eC0wvnCv', arguments='{}', name='transfer_to_user')]\n", + "[Prompt tokens: 483, Completion tokens: 11]\n", + "---------- flights_refunder ----------\n", + "[FunctionExecutionResult(content='Transferred to user, adopting the role of user immediately.', call_id='call_giMQVbQ7mXahC5G3eC0wvnCv')]\n", + "---------- flights_refunder ----------\n", + "Transferred to user, adopting the role of user immediately.\n", + "---------- Summary ----------\n", + "Number of messages: 8\n", + "Finish reason: Handoff to user from flights_refunder detected.\n", + "Total prompt tokens: 1260\n", + "Total completion tokens: 50\n", + "Duration: 1.79 seconds\n", + "---------- user ----------\n", + "Sure, it's 507811\n", + "---------- flights_refunder ----------\n", + "[FunctionCall(id='call_ACcFykJ3fPzanMwy1YGxG4L4', arguments='{\"flight_id\":\"507811\"}', name='refund_flight')]\n", + "[Prompt tokens: 530, Completion tokens: 18]\n", + "---------- flights_refunder ----------\n", + "[FunctionExecutionResult(content='Flight 507811 refunded', call_id='call_ACcFykJ3fPzanMwy1YGxG4L4')]\n", + "---------- flights_refunder ----------\n", + "Your flight with the reference number 507811 has been successfully refunded. If there is anything else you need help with, feel free to ask!\n", + "[Prompt tokens: 488, Completion tokens: 30]\n", + "---------- flights_refunder ----------\n", + "[FunctionCall(id='call_9NjAP8yD1qgwNL4Zfntt4dVb', arguments='{}', name='transfer_to_travel_agent')]\n", + "[Prompt tokens: 605, Completion tokens: 13]\n", + "---------- flights_refunder ----------\n", + "[FunctionExecutionResult(content='Transferred to travel_agent, adopting the role of travel_agent immediately.', call_id='call_9NjAP8yD1qgwNL4Zfntt4dVb')]\n", + "---------- flights_refunder ----------\n", + "Transferred to travel_agent, adopting the role of travel_agent immediately.\n", + "---------- travel_agent ----------\n", + "If you need further assistance with travel planning or any other inquiries, just let me know. Have a wonderful day!\n", + "[Prompt tokens: 495, Completion tokens: 24]\n", + "---------- travel_agent ----------\n", + "TERMINATE\n", + "[Prompt tokens: 525, Completion tokens: 4]\n", + "---------- Summary ----------\n", + "Number of messages: 9\n", + "Finish reason: Text 'TERMINATE' mentioned\n", + "Total prompt tokens: 2643\n", + "Total completion tokens: 89\n", + "Duration: 6.63 seconds\n" + ] + } + ], + "source": [ + "task = \"I need to refund my flight.\"\n", + "\n", + "\n", + "async def run_team_stream() -> None:\n", + " task_result = await Console(team.run_stream(task=task))\n", + " last_message = task_result.messages[-1]\n", + "\n", + " while isinstance(last_message, HandoffMessage) and last_message.target == \"user\":\n", + " user_message = input(\"User: \")\n", + "\n", + " task_result = await Console(\n", + " team.run_stream(task=HandoffMessage(source=\"user\", target=last_message.source, content=user_message))\n", + " )\n", + " last_message = task_result.messages[-1]\n", + "\n", + "\n", + "await run_team_stream()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Stock Research Example" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "![Stock Research](swarm_stock_research.svg)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This system is designed to perform stock research tasks by leveraging four agents:\n", + "\n", + "- **Planner**: The central coordinator that delegates specific tasks to specialized agents based on their expertise. The planner ensures that each agent is utilized efficiently and oversees the overall workflow.\n", + "- **Financial Analyst**: A specialized agent responsible for analyzing financial metrics and stock data using tools such as `get_stock_data`.\n", + "- **News Analyst**: An agent focused on gathering and summarizing recent news articles relevant to the stock, using tools such as `get_news`.\n", + "- **Writer**: An agent tasked with compiling the findings from the stock and news analysis into a cohesive final report.\n", + "\n", + "#### Workflow\n", + "1. The **Planner** initiates the research process by delegating tasks to the appropriate agents in a step-by-step manner.\n", + "2. Each agent performs its task independently and appends their work to the shared **message thread/history**. Rather than directly returning results to the planner, all agents contribute to and read from this shared message history. When agents generate their work using the LLM, they have access to this shared message history, which provides context and helps track the overall progress of the task.\n", + "3. Once an agent completes its task, it hands off control back to the planner.\n", + "4. The process continues until the planner determines that all necessary tasks have been completed and decides to terminate the workflow." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Tools" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "async def get_stock_data(symbol: str) -> Dict[str, Any]:\n", + " \"\"\"Get stock market data for a given symbol\"\"\"\n", + " return {\"price\": 180.25, \"volume\": 1000000, \"pe_ratio\": 65.4, \"market_cap\": \"700B\"}\n", + "\n", + "\n", + "async def get_news(query: str) -> List[Dict[str, str]]:\n", + " \"\"\"Get recent news articles about a company\"\"\"\n", + " return [\n", + " {\n", + " \"title\": \"Tesla Expands Cybertruck Production\",\n", + " \"date\": \"2024-03-20\",\n", + " \"summary\": \"Tesla ramps up Cybertruck manufacturing capacity at Gigafactory Texas, aiming to meet strong demand.\",\n", + " },\n", + " {\n", + " \"title\": \"Tesla FSD Beta Shows Promise\",\n", + " \"date\": \"2024-03-19\",\n", + " \"summary\": \"Latest Full Self-Driving beta demonstrates significant improvements in urban navigation and safety features.\",\n", + " },\n", + " {\n", + " \"title\": \"Model Y Dominates Global EV Sales\",\n", + " \"date\": \"2024-03-18\",\n", + " \"summary\": \"Tesla's Model Y becomes best-selling electric vehicle worldwide, capturing significant market share.\",\n", + " },\n", + " ]" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "model_client = OpenAIChatCompletionClient(\n", + " model=\"gpt-4o\",\n", + " # api_key=\"YOUR_API_KEY\",\n", + ")\n", + "\n", + "planner = AssistantAgent(\n", + " \"planner\",\n", + " model_client=model_client,\n", + " handoffs=[\"financial_analyst\", \"news_analyst\", \"writer\"],\n", + " system_message=\"\"\"You are a research planning coordinator.\n", + " Coordinate market research by delegating to specialized agents:\n", + " - Financial Analyst: For stock data analysis\n", + " - News Analyst: For news gathering and analysis\n", + " - Writer: For compiling final report\n", + " Always send your plan first, then handoff to appropriate agent.\n", + " Handoff to a single agent at a time.\n", + " Use TERMINATE when research is complete.\"\"\",\n", + ")\n", + "\n", + "financial_analyst = AssistantAgent(\n", + " \"financial_analyst\",\n", + " model_client=model_client,\n", + " handoffs=[\"planner\"],\n", + " tools=[get_stock_data],\n", + " system_message=\"\"\"You are a financial analyst.\n", + " Analyze stock market data using the get_stock_data tool.\n", + " Provide insights on financial metrics.\n", + " Always handoff back to planner when analysis is complete.\"\"\",\n", + ")\n", + "\n", + "news_analyst = AssistantAgent(\n", + " \"news_analyst\",\n", + " model_client=model_client,\n", + " handoffs=[\"planner\"],\n", + " tools=[get_news],\n", + " system_message=\"\"\"You are a news analyst.\n", + " Gather and analyze relevant news using the get_news tool.\n", + " Summarize key market insights from news.\n", + " Always handoff back to planner when analysis is complete.\"\"\",\n", + ")\n", + "\n", + "writer = AssistantAgent(\n", + " \"writer\",\n", + " model_client=model_client,\n", + " handoffs=[\"planner\"],\n", + " system_message=\"\"\"You are a financial report writer.\n", + " Compile research findings into clear, concise reports.\n", + " Always handoff back to planner when writing is complete.\"\"\",\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---------- user ----------\n", + "Conduct market research for TSLA stock\n", + "---------- planner ----------\n", + "[FunctionCall(id='call_IXFe9RcGbYGNf0V7B2hvDNJI', arguments='{}', name='transfer_to_financial_analyst')]\n", + "[Prompt tokens: 168, Completion tokens: 149]\n", + "---------- planner ----------\n", + "[FunctionExecutionResult(content='Transferred to financial_analyst, adopting the role of financial_analyst immediately.', call_id='call_IXFe9RcGbYGNf0V7B2hvDNJI')]\n", + "---------- planner ----------\n", + "Transferred to financial_analyst, adopting the role of financial_analyst immediately.\n", + "---------- financial_analyst ----------\n", + "[FunctionCall(id='call_2IYcTAXiufX1SBmnMJOG9HPq', arguments='{\"symbol\":\"TSLA\"}', name='get_stock_data')]\n", + "[Prompt tokens: 136, Completion tokens: 16]\n", + "---------- financial_analyst ----------\n", + "[FunctionExecutionResult(content=\"{'price': 180.25, 'volume': 1000000, 'pe_ratio': 65.4, 'market_cap': '700B'}\", call_id='call_2IYcTAXiufX1SBmnMJOG9HPq')]\n", + "---------- financial_analyst ----------\n", + "Here's the market research for TSLA (Tesla) stock:\n", + "\n", + "- **Current Price**: $180.25\n", + "- **Trading Volume**: 1,000,000 shares\n", + "- **Price to Earnings (P/E) Ratio**: 65.4\n", + "- **Market Capitalization**: $700 Billion\n", + "\n", + "These metrics can help evaluate Tesla's stock performance, value in the market, and overall investment appeal. If you need a specific analysis or additional data, feel free to let me know!\n", + "[Prompt tokens: 162, Completion tokens: 103]\n", + "---------- financial_analyst ----------\n", + "[FunctionCall(id='call_ji8SdlXI1uga2SNenIZMvPOR', arguments='{}', name='transfer_to_planner')]\n", + "[Prompt tokens: 310, Completion tokens: 12]\n", + "---------- financial_analyst ----------\n", + "[FunctionExecutionResult(content='Transferred to planner, adopting the role of planner immediately.', call_id='call_ji8SdlXI1uga2SNenIZMvPOR')]\n", + "---------- financial_analyst ----------\n", + "Transferred to planner, adopting the role of planner immediately.\n", + "---------- planner ----------\n", + "[FunctionCall(id='call_aQUm1B1jzvnWF9aWLwfn2VxS', arguments='{}', name='transfer_to_news_analyst')]\n", + "[Prompt tokens: 346, Completion tokens: 14]\n", + "---------- planner ----------\n", + "[FunctionExecutionResult(content='Transferred to news_analyst, adopting the role of news_analyst immediately.', call_id='call_aQUm1B1jzvnWF9aWLwfn2VxS')]\n", + "---------- planner ----------\n", + "Transferred to news_analyst, adopting the role of news_analyst immediately.\n", + "---------- news_analyst ----------\n", + "[FunctionCall(id='call_n5RmgbQgdyfE7EX5NUsKwApq', arguments='{\"query\":\"Tesla stock performance\"}', name='get_news')]\n", + "[Prompt tokens: 291, Completion tokens: 16]\n", + "---------- news_analyst ----------\n", + "[FunctionExecutionResult(content='[{\\'title\\': \\'Tesla Expands Cybertruck Production\\', \\'date\\': \\'2024-03-20\\', \\'summary\\': \\'Tesla ramps up Cybertruck manufacturing capacity at Gigafactory Texas, aiming to meet strong demand.\\'}, {\\'title\\': \\'Tesla FSD Beta Shows Promise\\', \\'date\\': \\'2024-03-19\\', \\'summary\\': \\'Latest Full Self-Driving beta demonstrates significant improvements in urban navigation and safety features.\\'}, {\\'title\\': \\'Model Y Dominates Global EV Sales\\', \\'date\\': \\'2024-03-18\\', \\'summary\\': \"Tesla\\'s Model Y becomes best-selling electric vehicle worldwide, capturing significant market share.\"}]', call_id='call_n5RmgbQgdyfE7EX5NUsKwApq')]\n", + "---------- news_analyst ----------\n", + "Here are some recent news articles related to TSLA (Tesla) stock that may influence its market performance:\n", + "\n", + "1. **Tesla Expands Cybertruck Production** (March 20, 2024)\n", + " - Tesla has ramped up its Cybertruck manufacturing capacity at the Gigafactory in Texas, aiming to meet the strong demand for this vehicle.\n", + "\n", + "2. **Tesla FSD Beta Shows Promise** (March 19, 2024)\n", + " - The latest Full Self-Driving (FSD) beta update demonstrates notable improvements in urban navigation and safety features, suggesting potential advancements in Tesla's autonomous driving technology.\n", + "\n", + "3. **Model Y Dominates Global EV Sales** (March 18, 2024)\n", + " - Tesla's Model Y has become the best-selling electric vehicle worldwide, capturing a significant share of the market, which could positively impact the company's revenue streams.\n", + "\n", + "If you'd like a more detailed analysis or further information, please let me know!\n", + "[Prompt tokens: 414, Completion tokens: 192]\n", + "---------- news_analyst ----------\n", + "[FunctionCall(id='call_7Ka5f5k2yZ8flfvZWKNXDQjL', arguments='{}', name='transfer_to_planner')]\n", + "[Prompt tokens: 654, Completion tokens: 12]\n", + "---------- news_analyst ----------\n", + "[FunctionExecutionResult(content='Transferred to planner, adopting the role of planner immediately.', call_id='call_7Ka5f5k2yZ8flfvZWKNXDQjL')]\n", + "---------- news_analyst ----------\n", + "Transferred to planner, adopting the role of planner immediately.\n", + "---------- planner ----------\n", + "[FunctionCall(id='call_zl0E18TZWoCPykYqG7jpR2mr', arguments='{}', name='transfer_to_writer')]\n", + "[Prompt tokens: 611, Completion tokens: 11]\n", + "---------- planner ----------\n", + "[FunctionExecutionResult(content='Transferred to writer, adopting the role of writer immediately.', call_id='call_zl0E18TZWoCPykYqG7jpR2mr')]\n", + "---------- planner ----------\n", + "Transferred to writer, adopting the role of writer immediately.\n", + "---------- writer ----------\n", + "### Market Research Report: Tesla (TSLA) Stock\n", + "\n", + "#### Stock Performance Overview\n", + "\n", + "- **Current Price**: $180.25\n", + "- **Trading Volume**: 1,000,000 shares\n", + "- **Price to Earnings (P/E) Ratio**: 65.4\n", + "- **Market Capitalization**: $700 Billion\n", + "\n", + "Tesla's stock is currently valued at $180.25 per share, with a high trading volume reflecting active investor interest. The P/E ratio of 65.4 indicates that the stock might be viewed as overvalued compared to traditional industries, but it is common for tech and innovation-driven companies. The market capitalization of $700 billion underscores Tesla’s significant presence in the automotive and technology markets.\n", + "\n", + "#### Recent News Impacting Tesla (TSLA)\n", + "\n", + "1. **Expansion of Cybertruck Production**: Tesla has increased its Cybertruck production at the Texas Gigafactory, responding to heightened demand. This expansion strategy could drive future revenues and solidify Tesla's innovative image.\n", + "\n", + "2. **Advancements in Full Self-Driving (FSD) Technology**: The latest Tesla FSD beta update highlights promising improvements. Enhanced safety and urban navigation may bolster Tesla's reputation in the autonomous vehicle domain, potentially increasing its market value.\n", + "\n", + "3. **Model Y's Global Sales Leadership**: The Model Y has emerged as the leading global electric vehicle in terms of sales. This achievement not only boosts Tesla's revenue streams but also cements its position as a leader in the EV segment.\n", + "\n", + "### Conclusion\n", + "\n", + "Tesla’s market dynamics show strong innovation and consumer interest, which reflect positively in its stock market valuation and significant news coverage. The company continues to lead in the electric vehicle market and expand its capabilities in autonomous driving, thereby potentially increasing its financial performance and market leadership.\n", + "\n", + "For further analysis or inquiries, feel free to reach out.\n", + "[Prompt tokens: 489, Completion tokens: 371]\n", + "---------- writer ----------\n", + "[FunctionCall(id='call_9buNd5ud2MTRyX50X2EjQJqp', arguments='{}', name='transfer_to_planner')]\n", + "[Prompt tokens: 865, Completion tokens: 12]\n", + "---------- writer ----------\n", + "[FunctionExecutionResult(content='Transferred to planner, adopting the role of planner immediately.', call_id='call_9buNd5ud2MTRyX50X2EjQJqp')]\n", + "---------- writer ----------\n", + "Transferred to planner, adopting the role of planner immediately.\n", + "---------- planner ----------\n", + "TERMINATE\n", + "[Prompt tokens: 1037, Completion tokens: 4]\n", + "---------- Summary ----------\n", + "Number of messages: 27\n", + "Finish reason: Text 'TERMINATE' mentioned\n", + "Total prompt tokens: 5483\n", + "Total completion tokens: 912\n", + "Duration: 15.26 seconds\n" + ] + } + ], + "source": [ + "# Define termination condition\n", + "text_termination = TextMentionTermination(\"TERMINATE\")\n", + "termination = text_termination\n", + "\n", + "research_team = Swarm(\n", + " participants=[planner, financial_analyst, news_analyst, writer], termination_condition=termination\n", + ")\n", + "\n", + "task = \"Conduct market research for TSLA stock\"\n", + "await Console(research_team.run_stream(task=task))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { + "kernelspec": { + "display_name": "autogen", + "language": "python", + "name": "python3" + }, "language_info": { - "name": "python" + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.7" } }, "nbformat": 4, diff --git a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/swarm_customer_support.svg b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/swarm_customer_support.svg new file mode 100644 index 000000000000..c2dcde2bba67 --- /dev/null +++ b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/swarm_customer_support.svg @@ -0,0 +1,3 @@ + + +
Application/User
Application/User
Team
Team
Travel Agent
Travel Agent
Flights Refunder Agent
Flights Refunder Age...
Handoff Message
Handoff Message
refund_flight
refund_flight
Handoff
Message
Handoff...
\ No newline at end of file diff --git a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/swarm_stock_research.svg b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/swarm_stock_research.svg new file mode 100644 index 000000000000..f75d43269caf --- /dev/null +++ b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/swarm_stock_research.svg @@ -0,0 +1,3 @@ + + +
Planner
Planner
Writer
Writer
News 
%3CmxGraphModel%3E%3Croot%3E%3CmxCell%20id%3D%220%22%2F%3E%3CmxCell%20id%3D%221%22%20parent%3D%220%22%2F%3E%3CmxCell%20id%3D%222%22%20value%3D%22Writer%22%20style%3D%22rounded%3D1%3BwhiteSpace%3Dwrap%3Bhtml%3D1%3BstrokeColor%3D%239999FF%3BgradientColor%3Ddefault%3BfillColor%3Dnone%3B%22%20vertex%3D%221%22%20parent%3D%221%22%3E%3CmxGeometry%20x%3D%22780%22%20y%3D%22377%22%20width%3D%22120%22%20height%3D%2260%22%20as%3D%22geometry%22%2F%3E%3C%2FmxCell%3E%3C%2Froot%3E%3C%2FmxGraphModel%3EAnalyst
News...
Financial
Analyst
Financial...
Handoff
Handoff
Handoff
Handoff
Handoff
Handoff
get_stock_data
get_stock_data
get_news
get_news
\ No newline at end of file diff --git a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/teams.ipynb b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/teams.ipynb index 0b5af2088b92..51976a5370d1 100644 --- a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/teams.ipynb +++ b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/teams.ipynb @@ -22,14 +22,14 @@ "\n", "AgentChat provides several preset teams that implements one or more [multi-agent design patterns](../../core-user-guide/design-patterns/index.md) to simplify development. Here is a list of the preset teams:\n", "\n", - "- {py:class}`~autogen_agentchat.teams.RoundRobinGroupChat`: All participants share context and takes turn to respond in a round-robin fashion.\n", - "- {py:class}`~autogen_agentchat.teams.SelectorGroupChat`: All participants share context and use a model-based selector (with custom override) to select the next agent to respond.\n", - "- {py:class}`~autogen_agentchat.teams.Swarm`: All participants share context and use {py:class}`~autogen_agentchat.messages.HandoffMessage`to pass control to the next agent.\n", + "- {py:class}`~autogen_agentchat.teams.RoundRobinGroupChat`: All participants share context and takes turn to respond in a round-robin fashion. We will cover this team in this section.\n", + "- {py:class}`~autogen_agentchat.teams.SelectorGroupChat`: All participants share context and use a model-based selector (with custom override) to select the next agent to respond. See [Selector Group Chat](./selector-group-chat.ipynb) for more details.\n", + "- {py:class}`~autogen_agentchat.teams.Swarm`: All participants share context and use {py:class}`~autogen_agentchat.messages.HandoffMessage`to pass control to the next agent. See [Swarm](./swarm.ipynb) for more details.\n", "\n", "At a high-level, a team API consists of the following methods:\n", "\n", - "- {py:meth}`~autogen_agentchat.base.TaskRunner.run`: To process a task, which can be a {py:class}`str`, {py:class}`~autogen_agentchat.messages.TextMessage`, or {py:class}`~autogen_agentchat.messages.MultiModalMessage`, and returns {py:class}`~autogen_agentchat.base.TaskResult`. The task can also be `None` to resume processing the previous task if the team has not been reset.\n", - "- {py:meth}`~autogen_agentchat.base.TaskRunner.run_stream`: Same as {py:meth}`~autogen_agentchat.base.TaskRunner.run`, but returns a async generator of messages and the final task result.\n", + "- {py:meth}`~autogen_agentchat.base.TaskRunner.run`: Process a task, which can be a {py:class}`str`, {py:class}`~autogen_agentchat.messages.TextMessage`, {py:class}`~autogen_agentchat.messages.MultiModalMessage`, or {py:class}`~autogen_agentchat.messages.HandoffMessage`, and returns {py:class}`~autogen_agentchat.base.TaskResult`. The task can also be `None` to resume processing the previous task if the team has not been reset.\n", + "- {py:meth}`~autogen_agentchat.base.TaskRunner.run_stream`: Similar to {py:meth}`~autogen_agentchat.base.TaskRunner.run`, but it returns an async generator of messages and the final task result.\n", "- {py:meth}`~autogen_agentchat.base.Team.reset`: To reset the team state if the next task is not related to the previous task. Otherwise, the team can utilize the context from the previous task to process the next one.\n", "\n", "In this section, we will be using the\n", @@ -42,6 +42,9 @@ "source": [ "## Round-Robin Group Chat\n", "\n", + "{py:class}`~autogen_agentchat.teams.RoundRobinGroupChat` is a simple team that allows all agents to share context and take turns to respond in a round-robin fashion.\n", + "On its turn, each agent broadcasts its response to all other agents in the team, so all agents have the same context.\n", + "\n", "We will start by creating a team with a single {py:class}`~autogen_agentchat.agents.AssistantAgent` agent\n", "and {py:class}`~autogen_agentchat.task.TextMentionTermination`\n", "termination condition that stops the team when a word is detected." @@ -764,16 +767,6 @@ "# Use `asyncio.run(Console(lazy_agent_team.run_stream(task=\"It is raining in New York.\")))` when running in a script.\n", "await Console(lazy_agent_team.run_stream(task=\"It is raining in New York.\"))" ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "```{note}\n", - "Currently the handoff termination approach does not work with {py:class}`~autogen_agentchat.teams.Swarm`.\n", - "Please stay tuned for the updates.\n", - "```" - ] } ], "metadata": { diff --git a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/termination.ipynb b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/termination.ipynb index e10942491286..67c7582a311e 100644 --- a/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/termination.ipynb +++ b/python/packages/autogen-core/docs/src/user-guide/agentchat-user-guide/tutorial/termination.ipynb @@ -6,56 +6,77 @@ "source": [ "# Termination \n", "\n", - "\n", - "In the previous section, we explored how to define agents, and organize them into teams that can solve tasks by communicating (a conversation). However, conversations can go on forever, and in many cases, we need to know _when_ to stop them. This is the role of the termination condition.\n", + "In the previous section, we explored how to define agents, and organize them into teams that can solve tasks. However, a run can go on forever, and in many cases, we need to know _when_ to stop them. This is the role of the termination condition.\n", "\n", "AgentChat supports several termination condition by providing a base {py:class}`~autogen_agentchat.base.TerminationCondition` class and several implementations that inherit from it.\n", "\n", - "A termination condition is a callable that takes a sequence of ChatMessage objects since the last time the condition was called, and returns a StopMessage if the conversation should be terminated, or None otherwise. Once a termination condition has been reached, it must be reset before it can be used again.\n", + "A termination condition is a callable that takes a sequece of {py:class}`~autogen_agentchat.messages.AgentMessage` objects **since the last time the condition was called**, and returns a {py:class}`~autogen_agentchat.messages.StopMessage` if the conversation should be terminated, or `None` otherwise.\n", + "Once a termination condition has been reached, it must be reset by calling {py:meth}`~autogen_agentchat.base.TerminationCondition.reset` before it can be used again.\n", "\n", "Some important things to note about termination conditions: \n", - "- They are stateful, and must be reset before they can be used again. \n", - "- They can be combined using the AND and OR operators. \n", - "- They are implemented/enforced by the team, and not by the agents. An agent may signal or request termination e.g., by sending a StopMessage, but the team is responsible for enforcing it.\n" + "- They are stateful but reset automatically after each run ({py:meth}`~autogen_agentchat.base.TaskRunner.run` or {py:meth}`~autogen_agentchat.base.TaskRunner.run_stream`) is finished.\n", + "- They can be combined using the AND and OR operators.\n", + "\n", + "```{note}\n", + "For group chat teams (i.e., {py:class}`~autogen_agentchat.teams.RoundRobinGroupChat`,\n", + "{py:class}`~autogen_agentchat.teams.SelectorGroupChat`, and {py:class}`~autogen_agentchat.teams.Swarm`),\n", + "the termination condition is called after each agent responds.\n", + "While a response may contain multiple inner messages, the team calls its termination condition just once for all the messages from a single response.\n", + "So the condition is called with the \"delta sequence\" of messages since the last time it was called.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# AutoGen provides several built-in termination conditions: \n", + "1. {py:class}`~autogen_agentchat.task.MaxMessageTermination`: Stops after a specified number of messages have been produced, including both agent and task messages.\n", + "2. {py:class}`~autogen_agentchat.task.TextMentionTermination`: Stops when specific text or string is mentioned in a message (e.g., \"TERMINATE\").\n", + "3. {py:class}`~autogen_agentchat.task.TokenUsageTermination`: Stops when a certain number of prompt or completion tokens are used. This requires the agents to report token usage in their messages.\n", + "4. {py:class}`~autogen_agentchat.task.TimeoutTermination`: Stops after a specified duration in seconds.\n", + "5. {py:class}`~autogen_agentchat.task.HandoffTermination`: Stops when a handoff to a specific target is requested. Handoff messages can be used to build patterns such as {py:class}`~autogen_agentchat.teams.Swarm`. This is useful when you want to pause the run and allow application or user to provide input when an agent hands off to them.\n", + "6. {py:class}`~autogen_agentchat.task.SourceMatchTermination`: Stops after a specific agent responds.\n", + "7. {py:class}`~autogen_agentchat.task.ExternalTermination`: Enables programmatic control of termination from outside the run. This is useful for UI integration (e.g., \"Stop\" buttons in chat interfaces).\n", + "8. {py:class}`~autogen_agentchat.task.StopMessageTermination`: Stops when a {py:class}`~autogen_agentchat.messages.StopMessage` is produced by an agent." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "To begin, let us define a simple team with only one agent and then explore how multiple termination conditions can be applied to guide the resulting behavior." + "To demonstrate the characteristics of termination conditions, we'll create a team consisting of two agents: a primary agent responsible for text generation and a critic agent that reviews and provides feedback on the generated text." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ - "import logging\n", - "\n", - "from autogen_agentchat import EVENT_LOGGER_NAME\n", - "from autogen_agentchat.agents import CodingAssistantAgent\n", - "from autogen_agentchat.logging import ConsoleLogHandler\n", - "from autogen_agentchat.task import MaxMessageTermination, TextMentionTermination\n", + "from autogen_agentchat.agents import AssistantAgent\n", + "from autogen_agentchat.task import Console, MaxMessageTermination, TextMentionTermination\n", "from autogen_agentchat.teams import RoundRobinGroupChat\n", - "from autogen_core.components.models import OpenAIChatCompletionClient\n", - "\n", - "logger = logging.getLogger(EVENT_LOGGER_NAME)\n", - "logger.addHandler(ConsoleLogHandler())\n", - "logger.setLevel(logging.INFO)\n", - "\n", + "from autogen_ext.models import OpenAIChatCompletionClient\n", "\n", "model_client = OpenAIChatCompletionClient(\n", - " model=\"gpt-4o-2024-08-06\",\n", + " model=\"gpt-4o\",\n", " temperature=1,\n", " # api_key=\"sk-...\", # Optional if you have an OPENAI_API_KEY env variable set.\n", ")\n", "\n", - "writing_assistant_agent = CodingAssistantAgent(\n", - " name=\"writing_assistant_agent\",\n", - " system_message=\"You are a helpful assistant that solve tasks by generating text responses and code.\",\n", + "# Create the primary agent.\n", + "primary_agent = AssistantAgent(\n", + " \"primary\",\n", + " model_client=model_client,\n", + " system_message=\"You are a helpful AI assistant.\",\n", + ")\n", + "\n", + "# Create the critic agent.\n", + "critic_agent = AssistantAgent(\n", + " \"critic\",\n", " model_client=model_client,\n", + " system_message=\"Provide constructive feedback for every message. Respond with 'APPROVE' to when your feedbacks are addressed.\",\n", ")" ] }, @@ -63,9 +84,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## MaxMessageTermination \n", - "\n", - "The simplest termination condition is the {py:class}`~autogen_agentchat.teams.MaxMessageTermination` condition, which terminates the conversation after a fixed number of messages. \n" + "Let's explore how termination conditions automatically reset after each `run` or `run_stream` call, allowing the team to resume its conversation from where it left off." ] }, { @@ -77,62 +96,117 @@ "name": "stdout", "output_type": "stream", "text": [ - "\n", - "--------------------------------------------------------------------------- \n", - "\u001b[91m[2024-10-19T12:19:28.807176]:\u001b[0m\n", - "\n", + "---------- user ----------\n", "Write a unique, Haiku about the weather in Paris\n", - "--------------------------------------------------------------------------- \n", - "\u001b[91m[2024-10-19T12:19:29.604935], writing_assistant_agent:\u001b[0m\n", - "\n", + "---------- primary ----------\n", "Gentle rain whispers, \n", - "Eiffel veiled in mist’s embrace, \n", - "Spring’s soft sigh in France.\n", - "--------------------------------------------------------------------------- \n", - "\u001b[91m[2024-10-19T12:19:30.168531], writing_assistant_agent:\u001b[0m\n", + "Cobblestones glisten softly— \n", + "Paris dreams in gray.\n", + "[Prompt tokens: 30, Completion tokens: 19]\n", + "---------- critic ----------\n", + "The Haiku captures the essence of a rainy day in Paris beautifully, and the imagery is vivid. However, it's important to ensure the use of the traditional 5-7-5 syllable structure for Haikus. Your current Haiku lines are composed of 4-7-5 syllables, which slightly deviates from the form. Consider revising the first line to fit the structure.\n", "\n", - "Gentle rain whispers, \n", - "Eiffel veiled in mist’s embrace, \n", - "Spring’s soft sigh in France.\n", - "--------------------------------------------------------------------------- \n", - "\u001b[91m[2024-10-19T12:19:31.213291], writing_assistant_agent:\u001b[0m\n", - "\n", - "Gentle rain whispers, \n", - "Eiffel veiled in mist’s embrace, \n", - "Spring’s soft sigh in France.\n", - "--------------------------------------------------------------------------- \n", - "\u001b[91m[2024-10-19T12:19:31.213655], Termination:\u001b[0m\n", + "For example:\n", + "Soft rain whispers down, \n", + "Cobblestones glisten softly — \n", + "Paris dreams in gray.\n", "\n", - "Maximal number of messages 3 reached, current message count: 3" + "This revision maintains the essence of your original lines while adhering to the traditional Haiku structure.\n", + "[Prompt tokens: 70, Completion tokens: 120]\n", + "---------- Summary ----------\n", + "Number of messages: 3\n", + "Finish reason: Maximum number of messages 3 reached, current message count: 3\n", + "Total prompt tokens: 100\n", + "Total completion tokens: 139\n", + "Duration: 3.34 seconds\n" ] + }, + { + "data": { + "text/plain": [ + "TaskResult(messages=[TextMessage(source='user', models_usage=None, content='Write a unique, Haiku about the weather in Paris'), TextMessage(source='primary', models_usage=RequestUsage(prompt_tokens=30, completion_tokens=19), content='Gentle rain whispers, \\nCobblestones glisten softly— \\nParis dreams in gray.'), TextMessage(source='critic', models_usage=RequestUsage(prompt_tokens=70, completion_tokens=120), content=\"The Haiku captures the essence of a rainy day in Paris beautifully, and the imagery is vivid. However, it's important to ensure the use of the traditional 5-7-5 syllable structure for Haikus. Your current Haiku lines are composed of 4-7-5 syllables, which slightly deviates from the form. Consider revising the first line to fit the structure.\\n\\nFor example:\\nSoft rain whispers down, \\nCobblestones glisten softly — \\nParis dreams in gray.\\n\\nThis revision maintains the essence of your original lines while adhering to the traditional Haiku structure.\")], stop_reason='Maximum number of messages 3 reached, current message count: 3')" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" } ], "source": [ "max_msg_termination = MaxMessageTermination(max_messages=3)\n", - "round_robin_team = RoundRobinGroupChat([writing_assistant_agent], termination_condition=max_msg_termination)\n", - "round_robin_team_result = await round_robin_team.run(task=\"Write a unique, Haiku about the weather in Paris\")" + "round_robin_team = RoundRobinGroupChat([primary_agent, critic_agent], termination_condition=max_msg_termination)\n", + "\n", + "# Use asyncio.run(...) if you are running this script as a standalone script.\n", + "await Console(round_robin_team.run_stream(task=\"Write a unique, Haiku about the weather in Paris\"))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "We see that the conversation is terminated after the specified number of messages have been sent by the agent." + "The conversation stopped after reaching the maximum message limit. Since the primary agent didn't get to respond to the feedback, let's continue the conversation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---------- primary ----------\n", + "Thank you for your feedback. Here is the revised Haiku:\n", + "\n", + "Soft rain whispers down, \n", + "Cobblestones glisten softly — \n", + "Paris dreams in gray.\n", + "[Prompt tokens: 181, Completion tokens: 32]\n", + "---------- critic ----------\n", + "The revised Haiku now follows the traditional 5-7-5 syllable pattern, and it still beautifully captures the atmospheric mood of Paris in the rain. The imagery and flow are both clear and evocative. Well done on making the adjustment! \n", + "\n", + "APPROVE\n", + "[Prompt tokens: 234, Completion tokens: 54]\n", + "---------- primary ----------\n", + "Thank you for your kind words and approval. I'm glad the revision meets your expectations and captures the essence of Paris. If you have any more requests or need further assistance, feel free to ask!\n", + "[Prompt tokens: 279, Completion tokens: 39]\n", + "---------- Summary ----------\n", + "Number of messages: 3\n", + "Finish reason: Maximum number of messages 3 reached, current message count: 3\n", + "Total prompt tokens: 694\n", + "Total completion tokens: 125\n", + "Duration: 6.43 seconds\n" + ] + }, + { + "data": { + "text/plain": [ + "TaskResult(messages=[TextMessage(source='primary', models_usage=RequestUsage(prompt_tokens=181, completion_tokens=32), content='Thank you for your feedback. Here is the revised Haiku:\\n\\nSoft rain whispers down, \\nCobblestones glisten softly — \\nParis dreams in gray.'), TextMessage(source='critic', models_usage=RequestUsage(prompt_tokens=234, completion_tokens=54), content='The revised Haiku now follows the traditional 5-7-5 syllable pattern, and it still beautifully captures the atmospheric mood of Paris in the rain. The imagery and flow are both clear and evocative. Well done on making the adjustment! \\n\\nAPPROVE'), TextMessage(source='primary', models_usage=RequestUsage(prompt_tokens=279, completion_tokens=39), content=\"Thank you for your kind words and approval. I'm glad the revision meets your expectations and captures the essence of Paris. If you have any more requests or need further assistance, feel free to ask!\")], stop_reason='Maximum number of messages 3 reached, current message count: 3')" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Use asyncio.run(...) if you are running this script as a standalone script.\n", + "await Console(round_robin_team.run_stream())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## StopMessageTermination\n", - "\n", - "In this scenario, the team terminates the conversation if any agent sends a `StopMessage`. So, when does an agent send a `StopMessage`? Typically, this is implemented in the `on_message` method of the agent, where the agent can check the incoming message and decide to send a `StopMessage` based on some condition. \n", - "\n", - "A common pattern here is prompt the agent (or some agent participating in the conversation) to emit a specific text string in it's response, which can be used to trigger the termination condition. \n", - "\n", - "In fact, if you review the code implementation for the default `CodingAssistantAgent` class provided by AgentChat, you will observe two things\n", - "- The default `system_message` instructs the agent to end their response with the word \"terminate\" if they deem the task to be completed\n", - "- in the `on_message` method, the agent checks if the incoming message contains the text \"terminate\" and returns a `StopMessage` if it does. " + "The team continued from where it left off, allowing the primary agent to respond to the feedback." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Next, let's show how termination conditions can be combined using the AND (`&`) and OR (`|`) operators to create more complex termination logic. For example, we'll create a team that stops either after 10 messages are generated or when the critic agent approves a message.\n" ] }, { @@ -144,37 +218,64 @@ "name": "stdout", "output_type": "stream", "text": [ - "\n", - "--------------------------------------------------------------------------- \n", - "\u001b[91m[2024-10-19T12:19:31.218855]:\u001b[0m\n", - "\n", + "---------- user ----------\n", "Write a unique, Haiku about the weather in Paris\n", - "--------------------------------------------------------------------------- \n", - "\u001b[91m[2024-10-19T12:19:31.752676], writing_assistant_agent:\u001b[0m\n", - "\n", - "Mist hugs the Eiffel, \n", - "Soft rain kisses cobblestones, \n", - "Autumn whispers past. \n", - "\n", - "TERMINATE\n", - "--------------------------------------------------------------------------- \n", - "\u001b[91m[2024-10-19T12:19:31.753265], Termination:\u001b[0m\n", + "---------- primary ----------\n", + "Spring breeze gently hums, \n", + "Cherry blossoms in full bloom— \n", + "Paris wakes to life.\n", + "[Prompt tokens: 467, Completion tokens: 19]\n", + "---------- critic ----------\n", + "The Haiku beautifully captures the awakening of Paris in the spring. The imagery of a gentle spring breeze and cherry blossoms in full bloom effectively conveys the rejuvenating feel of the season. The final line, \"Paris wakes to life,\" encapsulates the renewed energy and vibrancy of the city. The Haiku adheres to the 5-7-5 syllable structure and portrays a vivid seasonal transformation in a concise and poetic manner. Excellent work!\n", "\n", - "Stop message received" + "APPROVE\n", + "[Prompt tokens: 746, Completion tokens: 93]\n", + "---------- Summary ----------\n", + "Number of messages: 3\n", + "Finish reason: Text 'APPROVE' mentioned\n", + "Total prompt tokens: 1213\n", + "Total completion tokens: 112\n", + "Duration: 2.75 seconds\n" ] + }, + { + "data": { + "text/plain": [ + "TaskResult(messages=[TextMessage(source='user', models_usage=None, content='Write a unique, Haiku about the weather in Paris'), TextMessage(source='primary', models_usage=RequestUsage(prompt_tokens=467, completion_tokens=19), content='Spring breeze gently hums, \\nCherry blossoms in full bloom— \\nParis wakes to life.'), TextMessage(source='critic', models_usage=RequestUsage(prompt_tokens=746, completion_tokens=93), content='The Haiku beautifully captures the awakening of Paris in the spring. The imagery of a gentle spring breeze and cherry blossoms in full bloom effectively conveys the rejuvenating feel of the season. The final line, \"Paris wakes to life,\" encapsulates the renewed energy and vibrancy of the city. The Haiku adheres to the 5-7-5 syllable structure and portrays a vivid seasonal transformation in a concise and poetic manner. Excellent work!\\n\\nAPPROVE')], stop_reason=\"Text 'APPROVE' mentioned\")" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" } ], "source": [ - "writing_assistant_agent = CodingAssistantAgent(\n", - " name=\"writing_assistant_agent\",\n", - " system_message=\"You are a helpful assistant that solve tasks by generating text responses and code. Respond with TERMINATE when the task is done.\",\n", - " model_client=model_client,\n", - ")\n", + "max_msg_termination = MaxMessageTermination(max_messages=10)\n", + "text_termination = TextMentionTermination(\"APPROVE\")\n", + "combined_termination = max_msg_termination | text_termination\n", "\n", - "text_termination = TextMentionTermination(\"TERMINATE\")\n", - "round_robin_team = RoundRobinGroupChat([writing_assistant_agent], termination_condition=text_termination)\n", + "round_robin_team = RoundRobinGroupChat([primary_agent, critic_agent], termination_condition=combined_termination)\n", + "\n", + "# Use asyncio.run(...) if you are running this script as a standalone script.\n", + "await Console(round_robin_team.run_stream(task=\"Write a unique, Haiku about the weather in Paris\"))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The conversation stopped after the critic agent approved the message, although it could have also stopped if 10 messages were generated.\n", "\n", - "round_robin_team_result = await round_robin_team.run(task=\"Write a unique, Haiku about the weather in Paris\")" + "Alternatively, if we want to stop the run only when both conditions are met, we can use the AND (`&`) operator." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "combined_termination = max_msg_termination & text_termination" ] } ], @@ -194,7 +295,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.6" + "version": "3.11.5" } }, "nbformat": 4, diff --git a/python/packages/autogen-core/docs/src/user-guide/core-user-guide/cookbook/azure-openai-with-aad-auth.md b/python/packages/autogen-core/docs/src/user-guide/core-user-guide/cookbook/azure-openai-with-aad-auth.md index c8e4b632bd03..b347ed7de251 100644 --- a/python/packages/autogen-core/docs/src/user-guide/core-user-guide/cookbook/azure-openai-with-aad-auth.md +++ b/python/packages/autogen-core/docs/src/user-guide/core-user-guide/cookbook/azure-openai-with-aad-auth.md @@ -24,15 +24,11 @@ token_provider = get_bearer_token_provider( ) client = AzureOpenAIChatCompletionClient( - model="{your-azure-deployment}", + azure_deployment="{your-azure-deployment}", + model="{model-name, such as gpt-4o}", api_version="2024-02-01", azure_endpoint="https://{your-custom-endpoint}.openai.azure.com/", azure_ad_token_provider=token_provider, - model_capabilities={ - "vision":True, - "function_calling":True, - "json_output":True, - } ) ``` diff --git a/python/packages/autogen-core/docs/src/user-guide/core-user-guide/cookbook/local-llms-ollama-litellm.ipynb b/python/packages/autogen-core/docs/src/user-guide/core-user-guide/cookbook/local-llms-ollama-litellm.ipynb index a90cb440d6ce..80fde2b71017 100644 --- a/python/packages/autogen-core/docs/src/user-guide/core-user-guide/cookbook/local-llms-ollama-litellm.ipynb +++ b/python/packages/autogen-core/docs/src/user-guide/core-user-guide/cookbook/local-llms-ollama-litellm.ipynb @@ -46,10 +46,10 @@ "from autogen_core.components.models import (\n", " AssistantMessage,\n", " ChatCompletionClient,\n", - " OpenAIChatCompletionClient,\n", " SystemMessage,\n", " UserMessage,\n", - ")" + ")\n", + "from autogen_ext.models import OpenAIChatCompletionClient" ] }, { @@ -65,7 +65,7 @@ "metadata": {}, "outputs": [], "source": [ - "def get_model_client() -> OpenAIChatCompletionClient:\n", + "def get_model_client() -> OpenAIChatCompletionClient: # type: ignore\n", " \"Mimic OpenAI API using Local LLM Server.\"\n", " return OpenAIChatCompletionClient(\n", " model=\"gpt-4o\", # Need to use one of the OpenAI models as a placeholder for now.\n", @@ -233,7 +233,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.9" + "version": "3.12.7" } }, "nbformat": 4, diff --git a/python/packages/autogen-core/docs/src/user-guide/core-user-guide/cookbook/structured-output-agent.ipynb b/python/packages/autogen-core/docs/src/user-guide/core-user-guide/cookbook/structured-output-agent.ipynb index fa50d6da2797..95edbfa0c257 100644 --- a/python/packages/autogen-core/docs/src/user-guide/core-user-guide/cookbook/structured-output-agent.ipynb +++ b/python/packages/autogen-core/docs/src/user-guide/core-user-guide/cookbook/structured-output-agent.ipynb @@ -57,7 +57,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -79,15 +79,11 @@ "\n", "# Create the client with type-checked environment variables\n", "client = AzureOpenAIChatCompletionClient(\n", - " model=get_env_variable(\"AZURE_OPENAI_DEPLOYMENT_NAME\"),\n", + " azure_deployment=get_env_variable(\"AZURE_OPENAI_DEPLOYMENT_NAME\"),\n", + " model=get_env_variable(\"AZURE_OPENAI_MODEL\"),\n", " api_version=get_env_variable(\"AZURE_OPENAI_API_VERSION\"),\n", " azure_endpoint=get_env_variable(\"AZURE_OPENAI_ENDPOINT\"),\n", " api_key=get_env_variable(\"AZURE_OPENAI_API_KEY\"),\n", - " model_capabilities={\n", - " \"vision\": False,\n", - " \"function_calling\": True,\n", - " \"json_output\": True,\n", - " },\n", ")" ] }, diff --git a/python/packages/autogen-core/docs/src/user-guide/core-user-guide/core-concepts/architecture.md b/python/packages/autogen-core/docs/src/user-guide/core-user-guide/core-concepts/architecture.md index 3346cb68cb56..d3e38802ae3b 100644 --- a/python/packages/autogen-core/docs/src/user-guide/core-user-guide/core-concepts/architecture.md +++ b/python/packages/autogen-core/docs/src/user-guide/core-user-guide/core-concepts/architecture.md @@ -1,6 +1,6 @@ # Agent Runtime Environments -At the foundation level, the framework provides a _runtime envionment_, which facilitates +At the foundation level, the framework provides a _runtime environment_, which facilitates communication between agents, manages their identities and lifecycles, and enforce security and privacy boundaries. diff --git a/python/packages/autogen-core/docs/src/user-guide/core-user-guide/design-patterns/mixture-of-agents.ipynb b/python/packages/autogen-core/docs/src/user-guide/core-user-guide/design-patterns/mixture-of-agents.ipynb index dff7d18bd424..61b8b62bc221 100644 --- a/python/packages/autogen-core/docs/src/user-guide/core-user-guide/design-patterns/mixture-of-agents.ipynb +++ b/python/packages/autogen-core/docs/src/user-guide/core-user-guide/design-patterns/mixture-of-agents.ipynb @@ -41,7 +41,8 @@ "from autogen_core.application import SingleThreadedAgentRuntime\n", "from autogen_core.base import AgentId, MessageContext\n", "from autogen_core.components import RoutedAgent, message_handler\n", - "from autogen_core.components.models import ChatCompletionClient, OpenAIChatCompletionClient, SystemMessage, UserMessage" + "from autogen_core.components.models import ChatCompletionClient, SystemMessage, UserMessage\n", + "from autogen_ext.models import OpenAIChatCompletionClient" ] }, { diff --git a/python/packages/autogen-core/docs/src/user-guide/core-user-guide/design-patterns/multi-agent-debate.ipynb b/python/packages/autogen-core/docs/src/user-guide/core-user-guide/design-patterns/multi-agent-debate.ipynb index 3120363dd23b..72b653687915 100644 --- a/python/packages/autogen-core/docs/src/user-guide/core-user-guide/design-patterns/multi-agent-debate.ipynb +++ b/python/packages/autogen-core/docs/src/user-guide/core-user-guide/design-patterns/multi-agent-debate.ipynb @@ -50,10 +50,10 @@ " AssistantMessage,\n", " ChatCompletionClient,\n", " LLMMessage,\n", - " OpenAIChatCompletionClient,\n", " SystemMessage,\n", " UserMessage,\n", - ")" + ")\n", + "from autogen_ext.models import OpenAIChatCompletionClient" ] }, { diff --git a/python/packages/autogen-core/docs/src/user-guide/core-user-guide/framework/distributed-agent-runtime.ipynb b/python/packages/autogen-core/docs/src/user-guide/core-user-guide/framework/distributed-agent-runtime.ipynb index 833799c2096a..8bdc396a3196 100644 --- a/python/packages/autogen-core/docs/src/user-guide/core-user-guide/framework/distributed-agent-runtime.ipynb +++ b/python/packages/autogen-core/docs/src/user-guide/core-user-guide/framework/distributed-agent-runtime.ipynb @@ -21,6 +21,13 @@ "It also advertises the agents which they support to the host service,\n", "so the host service can deliver messages to the correct worker.\n", "\n", + "````{note}\n", + "The distributed agent runtime requires extra dependencies, install them using:\n", + "```bash\n", + "pip install autogen-core[grpc]==0.4.0.dev7\n", + "```\n", + "````\n", + "\n", "We can start a host service using {py:class}`~autogen_core.application.WorkerAgentRuntimeHost`." ] }, diff --git a/python/packages/autogen-core/docs/src/user-guide/core-user-guide/framework/model-clients.ipynb b/python/packages/autogen-core/docs/src/user-guide/core-user-guide/framework/model-clients.ipynb index cb17886b964a..e074eb970e0e 100644 --- a/python/packages/autogen-core/docs/src/user-guide/core-user-guide/framework/model-clients.ipynb +++ b/python/packages/autogen-core/docs/src/user-guide/core-user-guide/framework/model-clients.ipynb @@ -283,7 +283,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -294,16 +294,12 @@ "token_provider = get_bearer_token_provider(DefaultAzureCredential(), \"https://cognitiveservices.azure.com/.default\")\n", "\n", "az_model_client = AzureOpenAIChatCompletionClient(\n", - " model=\"{your-azure-deployment}\",\n", + " azure_deployment=\"{your-azure-deployment}\",\n", + " model=\"{model-name, such as gpt-4o}\",\n", " api_version=\"2024-06-01\",\n", " azure_endpoint=\"https://{your-custom-endpoint}.openai.azure.com/\",\n", " azure_ad_token_provider=token_provider, # Optional if you choose key-based authentication.\n", " # api_key=\"sk-...\", # For key-based authentication.\n", - " model_capabilities={\n", - " \"vision\": True,\n", - " \"function_calling\": True,\n", - " \"json_output\": True,\n", - " },\n", ")" ] }, diff --git a/python/packages/autogen-core/docs/src/user-guide/core-user-guide/framework/tools.ipynb b/python/packages/autogen-core/docs/src/user-guide/core-user-guide/framework/tools.ipynb index 183d878e4c8f..ff24095e8b50 100644 --- a/python/packages/autogen-core/docs/src/user-guide/core-user-guide/framework/tools.ipynb +++ b/python/packages/autogen-core/docs/src/user-guide/core-user-guide/framework/tools.ipynb @@ -161,12 +161,12 @@ "from autogen_core.components.models import (\n", " ChatCompletionClient,\n", " LLMMessage,\n", - " OpenAIChatCompletionClient,\n", " SystemMessage,\n", " UserMessage,\n", ")\n", "from autogen_core.components.tool_agent import ToolAgent, tool_agent_caller_loop\n", "from autogen_core.components.tools import FunctionTool, Tool, ToolSchema\n", + "from autogen_ext.models import OpenAIChatCompletionClient\n", "\n", "\n", "@dataclass\n", diff --git a/python/packages/autogen-core/pyproject.toml b/python/packages/autogen-core/pyproject.toml index 9d564ded4b88..064928590b0f 100644 --- a/python/packages/autogen-core/pyproject.toml +++ b/python/packages/autogen-core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "autogen-core" -version = "0.4.0.dev6" +version = "0.4.0.dev7" license = {file = "LICENSE-CODE"} description = "Foundational interfaces and agent runtime implementation for AutoGen" readme = "README.md" @@ -20,7 +20,6 @@ dependencies = [ "aiohttp", "typing-extensions", "pydantic<3.0.0,>=2.0.0", - "grpcio~=1.62.0", "protobuf~=4.25.1", "tiktoken", "opentelemetry-api~=1.27.0", @@ -28,6 +27,11 @@ dependencies = [ "jsonref~=1.1.0", ] +[project.optional-dependencies] +grpc = [ + "grpcio~=1.62.0", +] + [tool.uv] dev-dependencies = [ "aiofiles", @@ -68,6 +72,7 @@ dev-dependencies = [ "sphinx", "sphinxcontrib-apidoc", "autodoc_pydantic~=2.2", + "pygments", # Documentation tooling "sphinx-autobuild", @@ -149,3 +154,10 @@ ref = "docs-apidoc-all" [[tool.poe.tasks.docs-check.sequence]] cmd = "sphinx-build --fail-on-warning docs/src docs/build" + +[[tool.poe.tasks.docs-check-examples.sequence]] +ref = "docs-apidoc-all" + +[[tool.poe.tasks.docs-check-examples.sequence]] +cmd = "sphinx-build -b code_lint docs/src docs/build" + diff --git a/python/packages/autogen-core/samples/chess_game.py b/python/packages/autogen-core/samples/chess_game.py index 91ef9c8d0900..e0bc95e1d572 100644 --- a/python/packages/autogen-core/samples/chess_game.py +++ b/python/packages/autogen-core/samples/chess_game.py @@ -52,6 +52,7 @@ def get_legal_moves( def get_board(board: Board) -> str: + """Get the current board state.""" return str(board) @@ -63,25 +64,25 @@ def make_move( ) -> Annotated[str, "Result of the move."]: """Make a move on the board.""" validate_turn(board, player) - newMove = Move.from_uci(move) - board.push(newMove) + new_move = Move.from_uci(move) + board.push(new_move) # Print the move. print("-" * 50) print("Player:", player) - print("Move:", newMove.uci()) + print("Move:", new_move.uci()) print("Thinking:", thinking) print("Board:") print(board.unicode(borders=True)) # Get the piece name. - piece = board.piece_at(newMove.to_square) + piece = board.piece_at(new_move.to_square) assert piece is not None piece_symbol = piece.unicode_symbol() piece_name = get_piece_name(piece.piece_type) if piece_symbol.isupper(): piece_name = piece_name.capitalize() - return f"Moved {piece_name} ({piece_symbol}) from {SQUARE_NAMES[newMove.from_square]} to {SQUARE_NAMES[newMove.to_square]}." + return f"Moved {piece_name} ({piece_symbol}) from {SQUARE_NAMES[new_move.from_square]} to {SQUARE_NAMES[new_move.to_square]}." async def chess_game(runtime: AgentRuntime) -> None: # type: ignore @@ -152,7 +153,8 @@ def get_board_text() -> Annotated[str, "The current board state"]: ), ] - await runtime.register( + await ChatCompletionAgent.register( + runtime, "PlayerBlack", lambda: ChatCompletionAgent( description="Player playing black.", @@ -168,9 +170,11 @@ def get_board_text() -> Annotated[str, "The current board state"]: model_client=get_chat_completion_client_from_envs(model="gpt-4o"), tools=black_tools, ), - lambda: [DefaultSubscription()], ) - await runtime.register( + await runtime.add_subscription(DefaultSubscription(agent_type="PlayerBlack")) + + await ChatCompletionAgent.register( + runtime, "PlayerWhite", lambda: ChatCompletionAgent( description="Player playing white.", @@ -186,11 +190,13 @@ def get_board_text() -> Annotated[str, "The current board state"]: model_client=get_chat_completion_client_from_envs(model="gpt-4o"), tools=white_tools, ), - lambda: [DefaultSubscription()], ) + await runtime.add_subscription(DefaultSubscription(agent_type="PlayerWhite")) + # Create a group chat manager for the chess game to orchestrate a turn-based # conversation between the two agents. - await runtime.register( + await GroupChatManager.register( + runtime, "ChessGame", lambda: GroupChatManager( description="A chess game between two agents.", @@ -200,16 +206,21 @@ def get_board_text() -> Annotated[str, "The current board state"]: AgentId("PlayerBlack", AgentInstantiationContext.current_agent_id().key), ], # white goes first ), - lambda: [DefaultSubscription()], ) + await runtime.add_subscription(DefaultSubscription(agent_type="ChessGame")) async def main() -> None: + """Main Entrypoint.""" runtime = SingleThreadedAgentRuntime() await chess_game(runtime) runtime.start() - # Publish an initial message to trigger the group chat manager to start orchestration. - await runtime.publish_message(TextMessage(content="Game started.", source="System"), topic_id=DefaultTopicId()) + # Publish an initial message to trigger the group chat manager to start + # orchestration. + await runtime.publish_message( + TextMessage(content="Game started.", source="System"), + topic_id=DefaultTopicId(), + ) await runtime.stop_when_idle() diff --git a/python/packages/autogen-core/samples/common/utils.py b/python/packages/autogen-core/samples/common/utils.py index 4e77ac33232e..0765ceec561a 100644 --- a/python/packages/autogen-core/samples/common/utils.py +++ b/python/packages/autogen-core/samples/common/utils.py @@ -3,14 +3,13 @@ from autogen_core.components.models import ( AssistantMessage, - AzureOpenAIChatCompletionClient, ChatCompletionClient, FunctionExecutionResult, FunctionExecutionResultMessage, LLMMessage, - OpenAIChatCompletionClient, UserMessage, ) +from autogen_ext.models import AzureOpenAIChatCompletionClient, OpenAIChatCompletionClient from azure.identity import DefaultAzureCredential, get_bearer_token_provider from typing_extensions import Literal diff --git a/python/packages/autogen-core/samples/distributed-group-chat/_types.py b/python/packages/autogen-core/samples/distributed-group-chat/_types.py index 178446ca8c62..0e05d941c1ff 100644 --- a/python/packages/autogen-core/samples/distributed-group-chat/_types.py +++ b/python/packages/autogen-core/samples/distributed-group-chat/_types.py @@ -4,7 +4,7 @@ from autogen_core.components.models import ( LLMMessage, ) -from autogen_core.components.models.config import AzureOpenAIClientConfiguration +from autogen_ext.models import AzureOpenAIClientConfiguration from pydantic import BaseModel diff --git a/python/packages/autogen-core/samples/distributed-group-chat/_utils.py b/python/packages/autogen-core/samples/distributed-group-chat/_utils.py index 2c4b768e49da..431a94319fc5 100644 --- a/python/packages/autogen-core/samples/distributed-group-chat/_utils.py +++ b/python/packages/autogen-core/samples/distributed-group-chat/_utils.py @@ -5,7 +5,7 @@ import yaml from _types import AppConfig from autogen_core.base import MessageSerializer, try_get_known_serializers_for_type -from autogen_core.components.models.config import AzureOpenAIClientConfiguration +from autogen_ext.models import AzureOpenAIClientConfiguration from azure.identity import DefaultAzureCredential, get_bearer_token_provider diff --git a/python/packages/autogen-core/src/autogen_core/application/_single_threaded_agent_runtime.py b/python/packages/autogen-core/src/autogen_core/application/_single_threaded_agent_runtime.py index f511bd782d87..52d24c64d0cb 100644 --- a/python/packages/autogen-core/src/autogen_core/application/_single_threaded_agent_runtime.py +++ b/python/packages/autogen-core/src/autogen_core/application/_single_threaded_agent_runtime.py @@ -147,6 +147,23 @@ def _stop_when_idle(self) -> bool: return self._run_state == RunContext.RunState.UNTIL_IDLE and self._runtime.idle +def _warn_if_none(value: Any, handler_name: str) -> None: + """ + Utility function to check if the intervention handler returned None and issue a warning. + + Args: + value: The return value to check + handler_name: Name of the intervention handler method for the warning message + """ + if value is None: + warnings.warn( + f"Intervention handler {handler_name} returned None. This might be unintentional. " + "Consider returning the original message or DropMessage explicitly.", + RuntimeWarning, + stacklevel=2, + ) + + class SingleThreadedAgentRuntime(AgentRuntime): def __init__( self, @@ -433,6 +450,7 @@ async def process_next(self) -> None: ): try: temp_message = await handler.on_send(message, sender=sender, recipient=recipient) + _warn_if_none(temp_message, "on_send") except BaseException as e: future.set_exception(e) return @@ -456,6 +474,7 @@ async def process_next(self) -> None: ): try: temp_message = await handler.on_publish(message, sender=sender) + _warn_if_none(temp_message, "on_publish") except BaseException as e: # TODO: we should raise the intervention exception to the publisher. logger.error(f"Exception raised in in intervention handler: {e}", exc_info=True) @@ -474,6 +493,7 @@ async def process_next(self) -> None: for handler in self._intervention_handlers: try: temp_message = await handler.on_response(message, sender=sender, recipient=recipient) + _warn_if_none(temp_message, "on_response") except BaseException as e: # TODO: should we raise the exception to sender of the response instead? future.set_exception(e) diff --git a/python/packages/autogen-core/src/autogen_core/application/_utils.py b/python/packages/autogen-core/src/autogen_core/application/_utils.py new file mode 100644 index 000000000000..10fbfd1b8c8a --- /dev/null +++ b/python/packages/autogen-core/src/autogen_core/application/_utils.py @@ -0,0 +1,3 @@ +GRPC_IMPORT_ERROR_STR = ( + "Distributed runtime features require additional dependencies. Install them with: pip install autogen-core[grpc]" +) diff --git a/python/packages/autogen-core/src/autogen_core/application/_worker_runtime.py b/python/packages/autogen-core/src/autogen_core/application/_worker_runtime.py index ac3e00e4a4a9..2c405710876a 100644 --- a/python/packages/autogen-core/src/autogen_core/application/_worker_runtime.py +++ b/python/packages/autogen-core/src/autogen_core/application/_worker_runtime.py @@ -27,16 +27,11 @@ cast, ) -import grpc -from grpc.aio import StreamStreamCall from opentelemetry.trace import TracerProvider from typing_extensions import Self, deprecated -from autogen_core.base import JSON_DATA_CONTENT_TYPE -from autogen_core.base._serialization import MessageSerializer, SerializationRegistry -from autogen_core.base._type_helpers import ChannelArgumentType - from ..base import ( + JSON_DATA_CONTENT_TYPE, Agent, AgentId, AgentInstantiationContext, @@ -50,11 +45,19 @@ SubscriptionInstantiationContext, TopicId, ) +from ..base._serialization import MessageSerializer, SerializationRegistry +from ..base._type_helpers import ChannelArgumentType from ..components import TypeSubscription from ._helpers import SubscriptionManager, get_impl +from ._utils import GRPC_IMPORT_ERROR_STR from .protos import agent_worker_pb2, agent_worker_pb2_grpc from .telemetry import MessageRuntimeTracingConfig, TraceHelper, get_telemetry_grpc_metadata +try: + import grpc.aio +except ImportError as e: + raise ImportError(GRPC_IMPORT_ERROR_STR) from e + if TYPE_CHECKING: from .protos.agent_worker_pb2_grpc import AgentRpcAsyncStub @@ -140,6 +143,8 @@ async def _connect( # type: ignore ) -> None: stub: AgentRpcAsyncStub = agent_worker_pb2_grpc.AgentRpcStub(channel) # type: ignore + from grpc.aio import StreamStreamCall + # TODO: where do exceptions from reading the iterable go? How do we recover from those? recv_stream: StreamStreamCall[agent_worker_pb2.Message, agent_worker_pb2.Message] = stub.OpenChannel( # type: ignore QueueAsyncIterable(send_queue) diff --git a/python/packages/autogen-core/src/autogen_core/application/_worker_runtime_host.py b/python/packages/autogen-core/src/autogen_core/application/_worker_runtime_host.py index e6585098bdbd..d7fee07ff1f8 100644 --- a/python/packages/autogen-core/src/autogen_core/application/_worker_runtime_host.py +++ b/python/packages/autogen-core/src/autogen_core/application/_worker_runtime_host.py @@ -3,11 +3,14 @@ import signal from typing import Optional, Sequence -import grpc - -from autogen_core.base._type_helpers import ChannelArgumentType - +from ..base._type_helpers import ChannelArgumentType +from ._utils import GRPC_IMPORT_ERROR_STR from ._worker_runtime_host_servicer import WorkerAgentRuntimeHostServicer + +try: + import grpc +except ImportError as e: + raise ImportError(GRPC_IMPORT_ERROR_STR) from e from .protos import agent_worker_pb2_grpc logger = logging.getLogger("autogen_core") diff --git a/python/packages/autogen-core/src/autogen_core/application/_worker_runtime_host_servicer.py b/python/packages/autogen-core/src/autogen_core/application/_worker_runtime_host_servicer.py index 1ed794c35f29..3da50c56f048 100644 --- a/python/packages/autogen-core/src/autogen_core/application/_worker_runtime_host_servicer.py +++ b/python/packages/autogen-core/src/autogen_core/application/_worker_runtime_host_servicer.py @@ -4,11 +4,16 @@ from asyncio import Future, Task from typing import Any, Dict, Set -import grpc - from ..base import TopicId from ..components import TypeSubscription from ._helpers import SubscriptionManager +from ._utils import GRPC_IMPORT_ERROR_STR + +try: + import grpc +except ImportError as e: + raise ImportError(GRPC_IMPORT_ERROR_STR) from e + from .protos import agent_worker_pb2, agent_worker_pb2_grpc logger = logging.getLogger("autogen_core") diff --git a/python/packages/autogen-core/src/autogen_core/base/_agent_runtime.py b/python/packages/autogen-core/src/autogen_core/base/_agent_runtime.py index defb1de72921..a8e7f0096324 100644 --- a/python/packages/autogen-core/src/autogen_core/base/_agent_runtime.py +++ b/python/packages/autogen-core/src/autogen_core/base/_agent_runtime.py @@ -89,19 +89,6 @@ async def register( agent_factory (Callable[[], T]): The factory that creates the agent, where T is a concrete Agent type. Inside the factory, use `autogen_core.base.AgentInstantiationContext` to access variables like the current runtime and agent ID. subscriptions (Callable[[], list[Subscription]] | list[Subscription] | None, optional): The subscriptions that the agent should be subscribed to. Defaults to None. - Example: - .. code-block:: python - - runtime.register( - "chat_agent", - lambda: ChatCompletionAgent( - description="A generic chat agent.", - system_messages=[SystemMessage("You are a helpful assistant")], - model_client=OpenAIChatCompletionClient(model="gpt-4o"), - memory=BufferedChatMemory(buffer_size=10), - ), - ) - """ ... @@ -117,20 +104,6 @@ async def register_factory( Args: type (str): The type of agent this factory creates. It is not the same as agent class name. The `type` parameter is used to differentiate between different factory functions rather than agent classes. agent_factory (Callable[[], T]): The factory that creates the agent, where T is a concrete Agent type. Inside the factory, use `autogen_core.base.AgentInstantiationContext` to access variables like the current runtime and agent ID. - - Example: - .. code-block:: python - - runtime.register( - "chat_agent", - lambda: ChatCompletionAgent( - description="A generic chat agent.", - system_messages=[SystemMessage("You are a helpful assistant")], - model_client=OpenAIChatCompletionClient(model="gpt-4o"), - memory=BufferedChatMemory(buffer_size=10), - ), - ) - """ ... diff --git a/python/packages/autogen-core/src/autogen_core/base/_serialization.py b/python/packages/autogen-core/src/autogen_core/base/_serialization.py index 5c8e8cc772b0..51fd531feac5 100644 --- a/python/packages/autogen-core/src/autogen_core/base/_serialization.py +++ b/python/packages/autogen-core/src/autogen_core/base/_serialization.py @@ -195,7 +195,7 @@ def try_get_known_serializers_for_type(cls: type[Any]) -> list[MessageSerializer serializers: List[MessageSerializer[Any]] = [] if issubclass(cls, BaseModel): serializers.append(PydanticJsonMessageSerializer(cls)) - elif isinstance(cls, IsDataclass): + elif is_dataclass(cls): serializers.append(DataclassJsonMessageSerializer(cls)) elif issubclass(cls, Message): serializers.append(ProtobufMessageSerializer(cls)) diff --git a/python/packages/autogen-core/src/autogen_core/base/intervention.py b/python/packages/autogen-core/src/autogen_core/base/intervention.py index c9600ac9e13c..3b771c931a33 100644 --- a/python/packages/autogen-core/src/autogen_core/base/intervention.py +++ b/python/packages/autogen-core/src/autogen_core/base/intervention.py @@ -18,6 +18,11 @@ class DropMessage: ... class InterventionHandler(Protocol): + """An intervention handler is a class that can be used to modify, log or drop messages that are being processed by the :class:`autogen_core.base.AgentRuntime`. + + Note: Returning None from any of the intervention handler methods will result in a warning being issued and treated as "no change". If you intend to drop a message, you should return :class:`DropMessage` explicitly. + """ + async def on_send(self, message: Any, *, sender: AgentId | None, recipient: AgentId) -> Any | type[DropMessage]: ... async def on_publish(self, message: Any, *, sender: AgentId | None) -> Any | type[DropMessage]: ... async def on_response( @@ -26,6 +31,10 @@ async def on_response( class DefaultInterventionHandler(InterventionHandler): + """Simple class that provides a default implementation for all intervention + handler methods, that simply returns the message unchanged. Allows for easy + subclassing to override only the desired methods.""" + async def on_send(self, message: Any, *, sender: AgentId | None, recipient: AgentId) -> Any | type[DropMessage]: return message diff --git a/python/packages/autogen-core/src/autogen_core/components/_default_subscription.py b/python/packages/autogen-core/src/autogen_core/components/_default_subscription.py index aea5c381692d..3e1c0c4301cd 100644 --- a/python/packages/autogen-core/src/autogen_core/components/_default_subscription.py +++ b/python/packages/autogen-core/src/autogen_core/components/_default_subscription.py @@ -10,12 +10,6 @@ class DefaultSubscription(TypeSubscription): This topic by default uses the "default" topic type and attempts to detect the agent type to use based on the instantiation context. - Example: - - .. code-block:: python - - await runtime.register("MyAgent", agent_factory, lambda: [DefaultSubscription()]) - Args: topic_type (str, optional): The topic type to subscribe to. Defaults to "default". agent_type (str, optional): The agent type to use for the subscription. Defaults to None, in which case it will attempt to detect the agent type based on the instantiation context. diff --git a/python/packages/autogen-core/src/autogen_core/components/_routed_agent.py b/python/packages/autogen-core/src/autogen_core/components/_routed_agent.py index 9c21670d0605..e7f266bf49d6 100644 --- a/python/packages/autogen-core/src/autogen_core/components/_routed_agent.py +++ b/python/packages/autogen-core/src/autogen_core/components/_routed_agent.py @@ -422,9 +422,24 @@ class RoutedAgent(BaseAgent): .. code-block:: python + from dataclasses import dataclass from autogen_core.base import MessageContext from autogen_core.components import RoutedAgent, event, rpc - # Assume Message, MessageWithContent, and Response are defined elsewhere. + + + @dataclass + class Message: + pass + + + @dataclass + class MessageWithContent: + content: str + + + @dataclass + class Response: + pass class MyAgent(RoutedAgent): @@ -433,9 +448,10 @@ def __init__(self): @event async def handle_event_message(self, message: Message, ctx: MessageContext) -> None: - self.publish_message(MessageWithContent("event handled"), ctx.topic_id) + assert ctx.topic_id is not None + await self.publish_message(MessageWithContent("event handled"), ctx.topic_id) - @rpc(match=lambda message, ctx: message.content == "special") + @rpc(match=lambda message, ctx: message.content == "special") # type: ignore async def handle_special_rpc_message(self, message: MessageWithContent, ctx: MessageContext) -> Response: return Response() """ diff --git a/python/packages/autogen-core/src/autogen_core/components/_type_subscription.py b/python/packages/autogen-core/src/autogen_core/components/_type_subscription.py index d212317566f5..92709a457aec 100644 --- a/python/packages/autogen-core/src/autogen_core/components/_type_subscription.py +++ b/python/packages/autogen-core/src/autogen_core/components/_type_subscription.py @@ -14,6 +14,8 @@ class TypeSubscription(Subscription): .. code-block:: python + from autogen_core.components import TypeSubscription + subscription = TypeSubscription(topic_type="t1", agent_type="a1") In this case: diff --git a/python/packages/autogen-core/src/autogen_core/components/code_executor/_impl/local_commandline_code_executor.py b/python/packages/autogen-core/src/autogen_core/components/code_executor/_impl/local_commandline_code_executor.py index deca8355fbb3..31779f65679f 100644 --- a/python/packages/autogen-core/src/autogen_core/components/code_executor/_impl/local_commandline_code_executor.py +++ b/python/packages/autogen-core/src/autogen_core/components/code_executor/_impl/local_commandline_code_executor.py @@ -67,25 +67,31 @@ class LocalCommandLineCodeExecutor(CodeExecutor): import venv from pathlib import Path + import asyncio from autogen_core.base import CancellationToken from autogen_core.components.code_executor import CodeBlock, LocalCommandLineCodeExecutor - work_dir = Path("coding") - work_dir.mkdir(exist_ok=True) - venv_dir = work_dir / ".venv" - venv_builder = venv.EnvBuilder(with_pip=True) - venv_builder.create(venv_dir) - venv_context = venv_builder.ensure_directories(venv_dir) + async def example(): + work_dir = Path("coding") + work_dir.mkdir(exist_ok=True) - local_executor = LocalCommandLineCodeExecutor(work_dir=work_dir, virtual_env_context=venv_context) - await local_executor.execute_code_blocks( - code_blocks=[ - CodeBlock(language="bash", code="pip install matplotlib"), - ], - cancellation_token=CancellationToken(), - ) + venv_dir = work_dir / ".venv" + venv_builder = venv.EnvBuilder(with_pip=True) + venv_builder.create(venv_dir) + venv_context = venv_builder.ensure_directories(venv_dir) + + local_executor = LocalCommandLineCodeExecutor(work_dir=work_dir, virtual_env_context=venv_context) + await local_executor.execute_code_blocks( + code_blocks=[ + CodeBlock(language="bash", code="pip install matplotlib"), + ], + cancellation_token=CancellationToken(), + ) + + + asyncio.run(example()) """ diff --git a/python/packages/autogen-core/src/autogen_core/components/models/__init__.py b/python/packages/autogen-core/src/autogen_core/components/models/__init__.py index f57c82289ddc..9b12aa702edd 100644 --- a/python/packages/autogen-core/src/autogen_core/components/models/__init__.py +++ b/python/packages/autogen-core/src/autogen_core/components/models/__init__.py @@ -1,7 +1,3 @@ -import importlib -import warnings -from typing import TYPE_CHECKING, Any - from ._model_client import ChatCompletionClient, ModelCapabilities from ._types import ( AssistantMessage, @@ -17,13 +13,7 @@ UserMessage, ) -if TYPE_CHECKING: - from ._openai_client import AzureOpenAIChatCompletionClient, OpenAIChatCompletionClient - - __all__ = [ - "AzureOpenAIChatCompletionClient", - "OpenAIChatCompletionClient", "ModelCapabilities", "ChatCompletionClient", "SystemMessage", @@ -38,23 +28,3 @@ "TopLogprob", "ChatCompletionTokenLogprob", ] - - -def __getattr__(name: str) -> Any: - deprecated_classes = { - "AzureOpenAIChatCompletionClient": "autogen_ext.models.AzureOpenAIChatCompletionClient", - "OpenAIChatCompletionClient": "autogen_ext.modelsChatCompletionClient", - } - if name in deprecated_classes: - warnings.warn( - f"{name} moved to autogen_ext. " f"Please import it from {deprecated_classes[name]}.", - FutureWarning, - stacklevel=2, - ) - # Dynamically import the class from the current module - module = importlib.import_module("._openai_client", __name__) - attr = getattr(module, name) - # Cache the attribute in the module's global namespace - globals()[name] = attr - return attr - raise AttributeError(f"module {__name__} has no attribute {name}") diff --git a/python/packages/autogen-core/src/autogen_core/components/models/_model_info.py b/python/packages/autogen-core/src/autogen_core/components/models/_model_info.py deleted file mode 100644 index 2440d5b18682..000000000000 --- a/python/packages/autogen-core/src/autogen_core/components/models/_model_info.py +++ /dev/null @@ -1,122 +0,0 @@ -from typing import Dict - -from ._model_client import ModelCapabilities - -# Based on: https://platform.openai.com/docs/models/continuous-model-upgrades -# This is a moving target, so correctness is checked by the model value returned by openai against expected values at runtime`` -_MODEL_POINTERS = { - "gpt-4o": "gpt-4o-2024-08-06", - "gpt-4o-mini": "gpt-4o-mini-2024-07-18", - "gpt-4-turbo": "gpt-4-turbo-2024-04-09", - "gpt-4-turbo-preview": "gpt-4-0125-preview", - "gpt-4": "gpt-4-0613", - "gpt-4-32k": "gpt-4-32k-0613", - "gpt-3.5-turbo": "gpt-3.5-turbo-0125", - "gpt-3.5-turbo-16k": "gpt-3.5-turbo-16k-0613", -} - -_MODEL_CAPABILITIES: Dict[str, ModelCapabilities] = { - "gpt-4o-2024-08-06": { - "vision": True, - "function_calling": True, - "json_output": True, - }, - "gpt-4o-2024-05-13": { - "vision": True, - "function_calling": True, - "json_output": True, - }, - "gpt-4o-mini-2024-07-18": { - "vision": True, - "function_calling": True, - "json_output": True, - }, - "gpt-4-turbo-2024-04-09": { - "vision": True, - "function_calling": True, - "json_output": True, - }, - "gpt-4-0125-preview": { - "vision": False, - "function_calling": True, - "json_output": True, - }, - "gpt-4-1106-preview": { - "vision": False, - "function_calling": True, - "json_output": True, - }, - "gpt-4-1106-vision-preview": { - "vision": True, - "function_calling": False, - "json_output": False, - }, - "gpt-4-0613": { - "vision": False, - "function_calling": True, - "json_output": True, - }, - "gpt-4-32k-0613": { - "vision": False, - "function_calling": True, - "json_output": True, - }, - "gpt-3.5-turbo-0125": { - "vision": False, - "function_calling": True, - "json_output": True, - }, - "gpt-3.5-turbo-1106": { - "vision": False, - "function_calling": True, - "json_output": True, - }, - "gpt-3.5-turbo-instruct": { - "vision": False, - "function_calling": True, - "json_output": True, - }, - "gpt-3.5-turbo-0613": { - "vision": False, - "function_calling": True, - "json_output": True, - }, - "gpt-3.5-turbo-16k-0613": { - "vision": False, - "function_calling": True, - "json_output": True, - }, -} - -_MODEL_TOKEN_LIMITS: Dict[str, int] = { - "gpt-4o-2024-08-06": 128000, - "gpt-4o-2024-05-13": 128000, - "gpt-4o-mini-2024-07-18": 128000, - "gpt-4-turbo-2024-04-09": 128000, - "gpt-4-0125-preview": 128000, - "gpt-4-1106-preview": 128000, - "gpt-4-1106-vision-preview": 128000, - "gpt-4-0613": 8192, - "gpt-4-32k-0613": 32768, - "gpt-3.5-turbo-0125": 16385, - "gpt-3.5-turbo-1106": 16385, - "gpt-3.5-turbo-instruct": 4096, - "gpt-3.5-turbo-0613": 4096, - "gpt-3.5-turbo-16k-0613": 16385, -} - - -def resolve_model(model: str) -> str: - if model in _MODEL_POINTERS: - return _MODEL_POINTERS[model] - return model - - -def get_capabilities(model: str) -> ModelCapabilities: - resolved_model = resolve_model(model) - return _MODEL_CAPABILITIES[resolved_model] - - -def get_token_limit(model: str) -> int: - resolved_model = resolve_model(model) - return _MODEL_TOKEN_LIMITS[resolved_model] diff --git a/python/packages/autogen-core/src/autogen_core/components/models/_openai_client.py b/python/packages/autogen-core/src/autogen_core/components/models/_openai_client.py deleted file mode 100644 index 8ce8ddff2cbc..000000000000 --- a/python/packages/autogen-core/src/autogen_core/components/models/_openai_client.py +++ /dev/null @@ -1,901 +0,0 @@ -import asyncio -import inspect -import json -import logging -import math -import re -import warnings -from asyncio import Task -from typing import ( - Any, - AsyncGenerator, - Dict, - List, - Mapping, - Optional, - Sequence, - Set, - Type, - Union, - cast, -) - -import tiktoken -from openai import AsyncAzureOpenAI, AsyncOpenAI -from openai.types.chat import ( - ChatCompletion, - ChatCompletionAssistantMessageParam, - ChatCompletionContentPartParam, - ChatCompletionContentPartTextParam, - ChatCompletionMessageParam, - ChatCompletionMessageToolCallParam, - ChatCompletionRole, - ChatCompletionSystemMessageParam, - ChatCompletionToolMessageParam, - ChatCompletionToolParam, - ChatCompletionUserMessageParam, - ParsedChatCompletion, - ParsedChoice, - completion_create_params, -) -from openai.types.chat.chat_completion import Choice -from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice -from openai.types.shared_params import FunctionDefinition, FunctionParameters -from pydantic import BaseModel -from typing_extensions import Unpack - -from ...application.logging import EVENT_LOGGER_NAME, TRACE_LOGGER_NAME -from ...application.logging.events import LLMCallEvent -from ...base import CancellationToken -from .. import ( - FunctionCall, - Image, -) -from ..tools import Tool, ToolSchema -from . import _model_info -from ._model_client import ChatCompletionClient, ModelCapabilities -from ._types import ( - AssistantMessage, - ChatCompletionTokenLogprob, - CreateResult, - FunctionExecutionResultMessage, - LLMMessage, - RequestUsage, - SystemMessage, - TopLogprob, - UserMessage, -) -from .config import AzureOpenAIClientConfiguration, OpenAIClientConfiguration - -logger = logging.getLogger(EVENT_LOGGER_NAME) -trace_logger = logging.getLogger(TRACE_LOGGER_NAME) - -openai_init_kwargs = set(inspect.getfullargspec(AsyncOpenAI.__init__).kwonlyargs) -aopenai_init_kwargs = set(inspect.getfullargspec(AsyncAzureOpenAI.__init__).kwonlyargs) - -create_kwargs = set(completion_create_params.CompletionCreateParamsBase.__annotations__.keys()) | set( - ("timeout", "stream") -) -# Only single choice allowed -disallowed_create_args = set(["stream", "messages", "function_call", "functions", "n"]) -required_create_args: Set[str] = set(["model"]) - - -def _azure_openai_client_from_config(config: Mapping[str, Any]) -> AsyncAzureOpenAI: - # Take a copy - copied_config = dict(config).copy() - - # Do some fixups - copied_config["azure_deployment"] = copied_config.get("azure_deployment", config.get("model")) - if copied_config["azure_deployment"] is not None: - copied_config["azure_deployment"] = copied_config["azure_deployment"].replace(".", "") - copied_config["azure_endpoint"] = copied_config.get("azure_endpoint", copied_config.pop("base_url", None)) - - # Shave down the config to just the AzureOpenAIChatCompletionClient kwargs - azure_config = {k: v for k, v in copied_config.items() if k in aopenai_init_kwargs} - return AsyncAzureOpenAI(**azure_config) - - -def _openai_client_from_config(config: Mapping[str, Any]) -> AsyncOpenAI: - # Shave down the config to just the OpenAI kwargs - openai_config = {k: v for k, v in config.items() if k in openai_init_kwargs} - return AsyncOpenAI(**openai_config) - - -def _create_args_from_config(config: Mapping[str, Any]) -> Dict[str, Any]: - create_args = {k: v for k, v in config.items() if k in create_kwargs} - create_args_keys = set(create_args.keys()) - if not required_create_args.issubset(create_args_keys): - raise ValueError(f"Required create args are missing: {required_create_args - create_args_keys}") - if disallowed_create_args.intersection(create_args_keys): - raise ValueError(f"Disallowed create args are present: {disallowed_create_args.intersection(create_args_keys)}") - return create_args - - -# TODO check types -# oai_system_message_schema = type2schema(ChatCompletionSystemMessageParam) -# oai_user_message_schema = type2schema(ChatCompletionUserMessageParam) -# oai_assistant_message_schema = type2schema(ChatCompletionAssistantMessageParam) -# oai_tool_message_schema = type2schema(ChatCompletionToolMessageParam) - - -def type_to_role(message: LLMMessage) -> ChatCompletionRole: - if isinstance(message, SystemMessage): - return "system" - elif isinstance(message, UserMessage): - return "user" - elif isinstance(message, AssistantMessage): - return "assistant" - else: - return "tool" - - -def user_message_to_oai(message: UserMessage) -> ChatCompletionUserMessageParam: - assert_valid_name(message.source) - if isinstance(message.content, str): - return ChatCompletionUserMessageParam( - content=message.content, - role="user", - name=message.source, - ) - else: - parts: List[ChatCompletionContentPartParam] = [] - for part in message.content: - if isinstance(part, str): - oai_part = ChatCompletionContentPartTextParam( - text=part, - type="text", - ) - parts.append(oai_part) - elif isinstance(part, Image): - # TODO: support url based images - # TODO: support specifying details - parts.append(part.to_openai_format()) - else: - raise ValueError(f"Unknown content type: {part}") - return ChatCompletionUserMessageParam( - content=parts, - role="user", - name=message.source, - ) - - -def system_message_to_oai(message: SystemMessage) -> ChatCompletionSystemMessageParam: - return ChatCompletionSystemMessageParam( - content=message.content, - role="system", - ) - - -def func_call_to_oai(message: FunctionCall) -> ChatCompletionMessageToolCallParam: - return ChatCompletionMessageToolCallParam( - id=message.id, - function={ - "arguments": message.arguments, - "name": message.name, - }, - type="function", - ) - - -def tool_message_to_oai( - message: FunctionExecutionResultMessage, -) -> Sequence[ChatCompletionToolMessageParam]: - return [ - ChatCompletionToolMessageParam(content=x.content, role="tool", tool_call_id=x.call_id) for x in message.content - ] - - -def assistant_message_to_oai( - message: AssistantMessage, -) -> ChatCompletionAssistantMessageParam: - assert_valid_name(message.source) - if isinstance(message.content, list): - return ChatCompletionAssistantMessageParam( - tool_calls=[func_call_to_oai(x) for x in message.content], - role="assistant", - name=message.source, - ) - else: - return ChatCompletionAssistantMessageParam( - content=message.content, - role="assistant", - name=message.source, - ) - - -def to_oai_type(message: LLMMessage) -> Sequence[ChatCompletionMessageParam]: - if isinstance(message, SystemMessage): - return [system_message_to_oai(message)] - elif isinstance(message, UserMessage): - return [user_message_to_oai(message)] - elif isinstance(message, AssistantMessage): - return [assistant_message_to_oai(message)] - else: - return tool_message_to_oai(message) - - -def calculate_vision_tokens(image: Image, detail: str = "auto") -> int: - MAX_LONG_EDGE = 2048 - BASE_TOKEN_COUNT = 85 - TOKENS_PER_TILE = 170 - MAX_SHORT_EDGE = 768 - TILE_SIZE = 512 - - if detail == "low": - return BASE_TOKEN_COUNT - - width, height = image.image.size - - # Scale down to fit within a MAX_LONG_EDGE x MAX_LONG_EDGE square if necessary - - if width > MAX_LONG_EDGE or height > MAX_LONG_EDGE: - aspect_ratio = width / height - if aspect_ratio > 1: - # Width is greater than height - width = MAX_LONG_EDGE - height = int(MAX_LONG_EDGE / aspect_ratio) - else: - # Height is greater than or equal to width - height = MAX_LONG_EDGE - width = int(MAX_LONG_EDGE * aspect_ratio) - - # Resize such that the shortest side is MAX_SHORT_EDGE if both dimensions exceed MAX_SHORT_EDGE - aspect_ratio = width / height - if width > MAX_SHORT_EDGE and height > MAX_SHORT_EDGE: - if aspect_ratio > 1: - # Width is greater than height - height = MAX_SHORT_EDGE - width = int(MAX_SHORT_EDGE * aspect_ratio) - else: - # Height is greater than or equal to width - width = MAX_SHORT_EDGE - height = int(MAX_SHORT_EDGE / aspect_ratio) - - # Calculate the number of tiles based on TILE_SIZE - - tiles_width = math.ceil(width / TILE_SIZE) - tiles_height = math.ceil(height / TILE_SIZE) - total_tiles = tiles_width * tiles_height - # Calculate the total tokens based on the number of tiles and the base token count - - total_tokens = BASE_TOKEN_COUNT + TOKENS_PER_TILE * total_tiles - - return total_tokens - - -def _add_usage(usage1: RequestUsage, usage2: RequestUsage) -> RequestUsage: - return RequestUsage( - prompt_tokens=usage1.prompt_tokens + usage2.prompt_tokens, - completion_tokens=usage1.completion_tokens + usage2.completion_tokens, - ) - - -def convert_tools( - tools: Sequence[Tool | ToolSchema], -) -> List[ChatCompletionToolParam]: - result: List[ChatCompletionToolParam] = [] - for tool in tools: - if isinstance(tool, Tool): - tool_schema = tool.schema - else: - assert isinstance(tool, dict) - tool_schema = tool - - result.append( - ChatCompletionToolParam( - type="function", - function=FunctionDefinition( - name=tool_schema["name"], - description=(tool_schema["description"] if "description" in tool_schema else ""), - parameters=( - cast(FunctionParameters, tool_schema["parameters"]) if "parameters" in tool_schema else {} - ), - ), - ) - ) - # Check if all tools have valid names. - for tool_param in result: - assert_valid_name(tool_param["function"]["name"]) - return result - - -def normalize_name(name: str) -> str: - """ - LLMs sometimes ask functions while ignoring their own format requirements, this function should be used to replace invalid characters with "_". - - Prefer _assert_valid_name for validating user configuration or input - """ - return re.sub(r"[^a-zA-Z0-9_-]", "_", name)[:64] - - -def assert_valid_name(name: str) -> str: - """ - Ensure that configured names are valid, raises ValueError if not. - - For munging LLM responses use _normalize_name to ensure LLM specified names don't break the API. - """ - if not re.match(r"^[a-zA-Z0-9_-]+$", name): - raise ValueError(f"Invalid name: {name}. Only letters, numbers, '_' and '-' are allowed.") - if len(name) > 64: - raise ValueError(f"Invalid name: {name}. Name must be less than 64 characters.") - return name - - -class BaseOpenAIChatCompletionClient(ChatCompletionClient): - def __init__( - self, - client: Union[AsyncOpenAI, AsyncAzureOpenAI], - create_args: Dict[str, Any], - model_capabilities: Optional[ModelCapabilities] = None, - ): - self._client = client - if model_capabilities is None and isinstance(client, AsyncAzureOpenAI): - raise ValueError("AzureOpenAIChatCompletionClient requires explicit model capabilities") - elif model_capabilities is None: - self._model_capabilities = _model_info.get_capabilities(create_args["model"]) - else: - self._model_capabilities = model_capabilities - - self._resolved_model: Optional[str] = None - if "model" in create_args: - self._resolved_model = _model_info.resolve_model(create_args["model"]) - - if ( - "response_format" in create_args - and create_args["response_format"]["type"] == "json_object" - and not self._model_capabilities["json_output"] - ): - raise ValueError("Model does not support JSON output") - - self._create_args = create_args - self._total_usage = RequestUsage(prompt_tokens=0, completion_tokens=0) - self._actual_usage = RequestUsage(prompt_tokens=0, completion_tokens=0) - - @classmethod - def create_from_config(cls, config: Dict[str, Any]) -> ChatCompletionClient: - return OpenAIChatCompletionClient(**config) - - async def create( - self, - messages: Sequence[LLMMessage], - tools: Sequence[Tool | ToolSchema] = [], - json_output: Optional[bool] = None, - extra_create_args: Mapping[str, Any] = {}, - cancellation_token: Optional[CancellationToken] = None, - ) -> CreateResult: - # Make sure all extra_create_args are valid - extra_create_args_keys = set(extra_create_args.keys()) - if not create_kwargs.issuperset(extra_create_args_keys): - raise ValueError(f"Extra create args are invalid: {extra_create_args_keys - create_kwargs}") - - # Copy the create args and overwrite anything in extra_create_args - create_args = self._create_args.copy() - create_args.update(extra_create_args) - - # Declare use_beta_client - use_beta_client: bool = False - response_format_value: Optional[Type[BaseModel]] = None - - if "response_format" in create_args: - value = create_args["response_format"] - # If value is a Pydantic model class, use the beta client - if isinstance(value, type) and issubclass(value, BaseModel): - response_format_value = value - use_beta_client = True - else: - # response_format_value is not a Pydantic model class - use_beta_client = False - response_format_value = None - - # Remove 'response_format' from create_args to prevent passing it twice - create_args_no_response_format = {k: v for k, v in create_args.items() if k != "response_format"} - - # TODO: allow custom handling. - # For now we raise an error if images are present and vision is not supported - if self.capabilities["vision"] is False: - for message in messages: - if isinstance(message, UserMessage): - if isinstance(message.content, list) and any(isinstance(x, Image) for x in message.content): - raise ValueError("Model does not support vision and image was provided") - - if json_output is not None: - if self.capabilities["json_output"] is False and json_output is True: - raise ValueError("Model does not support JSON output") - - if json_output is True: - create_args["response_format"] = {"type": "json_object"} - else: - create_args["response_format"] = {"type": "text"} - - if self.capabilities["json_output"] is False and json_output is True: - raise ValueError("Model does not support JSON output") - - oai_messages_nested = [to_oai_type(m) for m in messages] - oai_messages = [item for sublist in oai_messages_nested for item in sublist] - - if self.capabilities["function_calling"] is False and len(tools) > 0: - raise ValueError("Model does not support function calling") - future: Union[Task[ParsedChatCompletion[BaseModel]], Task[ChatCompletion]] - if len(tools) > 0: - converted_tools = convert_tools(tools) - if use_beta_client: - # Pass response_format_value if it's not None - if response_format_value is not None: - future = asyncio.ensure_future( - self._client.beta.chat.completions.parse( - messages=oai_messages, - tools=converted_tools, - response_format=response_format_value, - **create_args_no_response_format, - ) - ) - else: - future = asyncio.ensure_future( - self._client.beta.chat.completions.parse( - messages=oai_messages, - tools=converted_tools, - **create_args_no_response_format, - ) - ) - else: - future = asyncio.ensure_future( - self._client.chat.completions.create( - messages=oai_messages, - stream=False, - tools=converted_tools, - **create_args, - ) - ) - else: - if use_beta_client: - if response_format_value is not None: - future = asyncio.ensure_future( - self._client.beta.chat.completions.parse( - messages=oai_messages, - response_format=response_format_value, - **create_args_no_response_format, - ) - ) - else: - future = asyncio.ensure_future( - self._client.beta.chat.completions.parse( - messages=oai_messages, - **create_args_no_response_format, - ) - ) - else: - future = asyncio.ensure_future( - self._client.chat.completions.create( - messages=oai_messages, - stream=False, - **create_args, - ) - ) - - if cancellation_token is not None: - cancellation_token.link_future(future) - result: Union[ParsedChatCompletion[BaseModel], ChatCompletion] = await future - if use_beta_client: - result = cast(ParsedChatCompletion[Any], result) - - if result.usage is not None: - logger.info( - LLMCallEvent( - prompt_tokens=result.usage.prompt_tokens, - completion_tokens=result.usage.completion_tokens, - ) - ) - - usage = RequestUsage( - # TODO backup token counting - prompt_tokens=result.usage.prompt_tokens if result.usage is not None else 0, - completion_tokens=(result.usage.completion_tokens if result.usage is not None else 0), - ) - - if self._resolved_model is not None: - if self._resolved_model != result.model: - warnings.warn( - f"Resolved model mismatch: {self._resolved_model} != {result.model}. Model mapping may be incorrect.", - stacklevel=2, - ) - - # Limited to a single choice currently. - choice: Union[ParsedChoice[Any], ParsedChoice[BaseModel], Choice] = result.choices[0] - if choice.finish_reason == "function_call": - raise ValueError("Function calls are not supported in this context") - - content: Union[str, List[FunctionCall]] - if choice.finish_reason == "tool_calls": - assert choice.message.tool_calls is not None - assert choice.message.function_call is None - - # NOTE: If OAI response type changes, this will need to be updated - content = [ - FunctionCall( - id=x.id, - arguments=x.function.arguments, - name=normalize_name(x.function.name), - ) - for x in choice.message.tool_calls - ] - finish_reason = "function_calls" - else: - finish_reason = choice.finish_reason - content = choice.message.content or "" - logprobs: Optional[List[ChatCompletionTokenLogprob]] = None - if choice.logprobs and choice.logprobs.content: - logprobs = [ - ChatCompletionTokenLogprob( - token=x.token, - logprob=x.logprob, - top_logprobs=[TopLogprob(logprob=y.logprob, bytes=y.bytes) for y in x.top_logprobs], - bytes=x.bytes, - ) - for x in choice.logprobs.content - ] - response = CreateResult( - finish_reason=finish_reason, # type: ignore - content=content, - usage=usage, - cached=False, - logprobs=logprobs, - ) - - _add_usage(self._actual_usage, usage) - _add_usage(self._total_usage, usage) - - # TODO - why is this cast needed? - return response - - async def create_stream( - self, - messages: Sequence[LLMMessage], - tools: Sequence[Tool | ToolSchema] = [], - json_output: Optional[bool] = None, - extra_create_args: Mapping[str, Any] = {}, - cancellation_token: Optional[CancellationToken] = None, - ) -> AsyncGenerator[Union[str, CreateResult], None]: - """ - Creates an AsyncGenerator that will yield a stream of chat completions based on the provided messages and tools. - - Args: - messages (Sequence[LLMMessage]): A sequence of messages to be processed. - tools (Sequence[Tool | ToolSchema], optional): A sequence of tools to be used in the completion. Defaults to `[]`. - json_output (Optional[bool], optional): If True, the output will be in JSON format. Defaults to None. - extra_create_args (Mapping[str, Any], optional): Additional arguments for the creation process. Default to `{}`. - cancellation_token (Optional[CancellationToken], optional): A token to cancel the operation. Defaults to None. - - Yields: - AsyncGenerator[Union[str, CreateResult], None]: A generator yielding the completion results as they are produced. - - In streaming, the default behaviour is not return token usage counts. See: [OpenAI API reference for possible args](https://platform.openai.com/docs/api-reference/chat/create). - However `extra_create_args={"stream_options": {"include_usage": True}}` will (if supported by the accessed API) - return a final chunk with usage set to a RequestUsage object having prompt and completion token counts, - all preceding chunks will have usage as None. See: [stream_options](https://platform.openai.com/docs/api-reference/chat/create#chat-create-stream_options). - - Other examples of OPENAI supported arguments that can be included in `extra_create_args`: - - `temperature` (float): Controls the randomness of the output. Higher values (e.g., 0.8) make the output more random, while lower values (e.g., 0.2) make it more focused and deterministic. - - `max_tokens` (int): The maximum number of tokens to generate in the completion. - - `top_p` (float): An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. - - `frequency_penalty` (float): A value between -2.0 and 2.0 that penalizes new tokens based on their existing frequency in the text so far, decreasing the likelihood of repeated phrases. - - `presence_penalty` (float): A value between -2.0 and 2.0 that penalizes new tokens based on whether they appear in the text so far, encouraging the model to talk about new topics. - """ - # Make sure all extra_create_args are valid - extra_create_args_keys = set(extra_create_args.keys()) - if not create_kwargs.issuperset(extra_create_args_keys): - raise ValueError(f"Extra create args are invalid: {extra_create_args_keys - create_kwargs}") - - # Copy the create args and overwrite anything in extra_create_args - create_args = self._create_args.copy() - create_args.update(extra_create_args) - - oai_messages_nested = [to_oai_type(m) for m in messages] - oai_messages = [item for sublist in oai_messages_nested for item in sublist] - - # TODO: allow custom handling. - # For now we raise an error if images are present and vision is not supported - if self.capabilities["vision"] is False: - for message in messages: - if isinstance(message, UserMessage): - if isinstance(message.content, list) and any(isinstance(x, Image) for x in message.content): - raise ValueError("Model does not support vision and image was provided") - - if json_output is not None: - if self.capabilities["json_output"] is False and json_output is True: - raise ValueError("Model does not support JSON output") - - if json_output is True: - create_args["response_format"] = {"type": "json_object"} - else: - create_args["response_format"] = {"type": "text"} - - if len(tools) > 0: - converted_tools = convert_tools(tools) - stream_future = asyncio.ensure_future( - self._client.chat.completions.create( - messages=oai_messages, - stream=True, - tools=converted_tools, - **create_args, - ) - ) - else: - stream_future = asyncio.ensure_future( - self._client.chat.completions.create(messages=oai_messages, stream=True, **create_args) - ) - if cancellation_token is not None: - cancellation_token.link_future(stream_future) - stream = await stream_future - choice: Union[ParsedChoice[Any], ParsedChoice[BaseModel], ChunkChoice] = cast(ChunkChoice, None) - chunk = None - stop_reason = None - maybe_model = None - content_deltas: List[str] = [] - full_tool_calls: Dict[int, FunctionCall] = {} - completion_tokens = 0 - logprobs: Optional[List[ChatCompletionTokenLogprob]] = None - while True: - try: - chunk_future = asyncio.ensure_future(anext(stream)) - if cancellation_token is not None: - cancellation_token.link_future(chunk_future) - chunk = await chunk_future - - # to process usage chunk in streaming situations - # add stream_options={"include_usage": True} in the initialization of OpenAIChatCompletionClient(...) - # However the different api's - # OPENAI api usage chunk produces no choices so need to check if there is a choice - # liteLLM api usage chunk does produce choices - choice = ( - chunk.choices[0] - if len(chunk.choices) > 0 - else choice - if chunk.usage is not None and stop_reason is not None - else cast(ChunkChoice, None) - ) - - # for liteLLM chunk usage, do the following hack keeping the pervious chunk.stop_reason (if set). - # set the stop_reason for the usage chunk to the prior stop_reason - stop_reason = choice.finish_reason if chunk.usage is None and stop_reason is None else stop_reason - maybe_model = chunk.model - # First try get content - if choice.delta.content is not None: - content_deltas.append(choice.delta.content) - if len(choice.delta.content) > 0: - yield choice.delta.content - continue - - # Otherwise, get tool calls - if choice.delta.tool_calls is not None: - for tool_call_chunk in choice.delta.tool_calls: - idx = tool_call_chunk.index - if idx not in full_tool_calls: - # We ignore the type hint here because we want to fill in type when the delta provides it - full_tool_calls[idx] = FunctionCall(id="", arguments="", name="") - - if tool_call_chunk.id is not None: - full_tool_calls[idx].id += tool_call_chunk.id - - if tool_call_chunk.function is not None: - if tool_call_chunk.function.name is not None: - full_tool_calls[idx].name += tool_call_chunk.function.name - if tool_call_chunk.function.arguments is not None: - full_tool_calls[idx].arguments += tool_call_chunk.function.arguments - if choice.logprobs and choice.logprobs.content: - logprobs = [ - ChatCompletionTokenLogprob( - token=x.token, - logprob=x.logprob, - top_logprobs=[TopLogprob(logprob=y.logprob, bytes=y.bytes) for y in x.top_logprobs], - bytes=x.bytes, - ) - for x in choice.logprobs.content - ] - - except StopAsyncIteration: - break - - model = maybe_model or create_args["model"] - model = model.replace("gpt-35", "gpt-3.5") # hack for Azure API - - if chunk and chunk.usage: - prompt_tokens = chunk.usage.prompt_tokens - else: - prompt_tokens = 0 - - if stop_reason is None: - raise ValueError("No stop reason found") - - content: Union[str, List[FunctionCall]] - if len(content_deltas) > 1: - content = "".join(content_deltas) - if chunk and chunk.usage: - completion_tokens = chunk.usage.completion_tokens - else: - completion_tokens = 0 - else: - completion_tokens = 0 - # TODO: fix assumption that dict values were added in order and actually order by int index - # for tool_call in full_tool_calls.values(): - # # value = json.dumps(tool_call) - # # completion_tokens += count_token(value, model=model) - # completion_tokens += 0 - content = list(full_tool_calls.values()) - - usage = RequestUsage( - prompt_tokens=prompt_tokens, - completion_tokens=completion_tokens, - ) - if stop_reason == "function_call": - raise ValueError("Function calls are not supported in this context") - if stop_reason == "tool_calls": - stop_reason = "function_calls" - - result = CreateResult( - finish_reason=stop_reason, # type: ignore - content=content, - usage=usage, - cached=False, - logprobs=logprobs, - ) - - _add_usage(self._actual_usage, usage) - _add_usage(self._total_usage, usage) - - yield result - - def actual_usage(self) -> RequestUsage: - return self._actual_usage - - def total_usage(self) -> RequestUsage: - return self._total_usage - - def count_tokens(self, messages: Sequence[LLMMessage], tools: Sequence[Tool | ToolSchema] = []) -> int: - model = self._create_args["model"] - try: - encoding = tiktoken.encoding_for_model(model) - except KeyError: - trace_logger.warning(f"Model {model} not found. Using cl100k_base encoding.") - encoding = tiktoken.get_encoding("cl100k_base") - tokens_per_message = 3 - tokens_per_name = 1 - num_tokens = 0 - - # Message tokens. - for message in messages: - num_tokens += tokens_per_message - oai_message = to_oai_type(message) - for oai_message_part in oai_message: - for key, value in oai_message_part.items(): - if value is None: - continue - - if isinstance(message, UserMessage) and isinstance(value, list): - typed_message_value = cast(List[ChatCompletionContentPartParam], value) - - assert len(typed_message_value) == len( - message.content - ), "Mismatch in message content and typed message value" - - # We need image properties that are only in the original message - for part, content_part in zip(typed_message_value, message.content, strict=False): - if isinstance(content_part, Image): - # TODO: add detail parameter - num_tokens += calculate_vision_tokens(content_part) - elif isinstance(part, str): - num_tokens += len(encoding.encode(part)) - else: - try: - serialized_part = json.dumps(part) - num_tokens += len(encoding.encode(serialized_part)) - except TypeError: - trace_logger.warning(f"Could not convert {part} to string, skipping.") - else: - if not isinstance(value, str): - try: - value = json.dumps(value) - except TypeError: - trace_logger.warning(f"Could not convert {value} to string, skipping.") - continue - num_tokens += len(encoding.encode(value)) - if key == "name": - num_tokens += tokens_per_name - num_tokens += 3 # every reply is primed with <|start|>assistant<|message|> - - # Tool tokens. - oai_tools = convert_tools(tools) - for tool in oai_tools: - function = tool["function"] - tool_tokens = len(encoding.encode(function["name"])) - if "description" in function: - tool_tokens += len(encoding.encode(function["description"])) - tool_tokens -= 2 - if "parameters" in function: - parameters = function["parameters"] - if "properties" in parameters: - assert isinstance(parameters["properties"], dict) - for propertiesKey in parameters["properties"]: # pyright: ignore - assert isinstance(propertiesKey, str) - tool_tokens += len(encoding.encode(propertiesKey)) - v = parameters["properties"][propertiesKey] # pyright: ignore - for field in v: # pyright: ignore - if field == "type": - tool_tokens += 2 - tool_tokens += len(encoding.encode(v["type"])) # pyright: ignore - elif field == "description": - tool_tokens += 2 - tool_tokens += len(encoding.encode(v["description"])) # pyright: ignore - elif field == "enum": - tool_tokens -= 3 - for o in v["enum"]: # pyright: ignore - tool_tokens += 3 - tool_tokens += len(encoding.encode(o)) # pyright: ignore - else: - trace_logger.warning(f"Not supported field {field}") - tool_tokens += 11 - if len(parameters["properties"]) == 0: # pyright: ignore - tool_tokens -= 2 - num_tokens += tool_tokens - num_tokens += 12 - return num_tokens - - def remaining_tokens(self, messages: Sequence[LLMMessage], tools: Sequence[Tool | ToolSchema] = []) -> int: - token_limit = _model_info.get_token_limit(self._create_args["model"]) - return token_limit - self.count_tokens(messages, tools) - - @property - def capabilities(self) -> ModelCapabilities: - return self._model_capabilities - - -class OpenAIChatCompletionClient(BaseOpenAIChatCompletionClient): - def __init__(self, **kwargs: Unpack[OpenAIClientConfiguration]): - if "model" not in kwargs: - raise ValueError("model is required for OpenAIChatCompletionClient") - - model_capabilities: Optional[ModelCapabilities] = None - copied_args = dict(kwargs).copy() - if "model_capabilities" in kwargs: - model_capabilities = kwargs["model_capabilities"] - del copied_args["model_capabilities"] - - client = _openai_client_from_config(copied_args) - create_args = _create_args_from_config(copied_args) - self._raw_config = copied_args - super().__init__(client, create_args, model_capabilities) - - def __getstate__(self) -> Dict[str, Any]: - state = self.__dict__.copy() - state["_client"] = None - return state - - def __setstate__(self, state: Dict[str, Any]) -> None: - self.__dict__.update(state) - self._client = _openai_client_from_config(state["_raw_config"]) - - -class AzureOpenAIChatCompletionClient(BaseOpenAIChatCompletionClient): - def __init__(self, **kwargs: Unpack[AzureOpenAIClientConfiguration]): - if "model" not in kwargs: - raise ValueError("model is required for OpenAIChatCompletionClient") - - model_capabilities: Optional[ModelCapabilities] = None - copied_args = dict(kwargs).copy() - if "model_capabilities" in kwargs: - model_capabilities = kwargs["model_capabilities"] - del copied_args["model_capabilities"] - - client = _azure_openai_client_from_config(copied_args) - create_args = _create_args_from_config(copied_args) - self._raw_config = copied_args - super().__init__(client, create_args, model_capabilities) - - def __getstate__(self) -> Dict[str, Any]: - state = self.__dict__.copy() - state["_client"] = None - return state - - def __setstate__(self, state: Dict[str, Any]) -> None: - self.__dict__.update(state) - self._client = _azure_openai_client_from_config(state["_raw_config"]) diff --git a/python/packages/autogen-core/src/autogen_core/components/models/config/__init__.py b/python/packages/autogen-core/src/autogen_core/components/models/config/__init__.py deleted file mode 100644 index d1edcf8c62f9..000000000000 --- a/python/packages/autogen-core/src/autogen_core/components/models/config/__init__.py +++ /dev/null @@ -1,52 +0,0 @@ -from typing import Awaitable, Callable, Dict, List, Literal, Optional, Union - -from typing_extensions import Required, TypedDict - -from .._model_client import ModelCapabilities - - -class ResponseFormat(TypedDict): - type: Literal["text", "json_object"] - - -class CreateArguments(TypedDict, total=False): - frequency_penalty: Optional[float] - logit_bias: Optional[Dict[str, int]] - max_tokens: Optional[int] - n: Optional[int] - presence_penalty: Optional[float] - response_format: ResponseFormat - seed: Optional[int] - stop: Union[Optional[str], List[str]] - temperature: Optional[float] - top_p: Optional[float] - user: str - - -AsyncAzureADTokenProvider = Callable[[], Union[str, Awaitable[str]]] - - -class BaseOpenAIClientConfiguration(CreateArguments, total=False): - model: str - api_key: str - timeout: Union[float, None] - max_retries: int - - -# See OpenAI docs for explanation of these parameters -class OpenAIClientConfiguration(BaseOpenAIClientConfiguration, total=False): - organization: str - base_url: str - # Not required - model_capabilities: ModelCapabilities - - -class AzureOpenAIClientConfiguration(BaseOpenAIClientConfiguration, total=False): - # Azure specific - azure_endpoint: Required[str] - azure_deployment: str - api_version: Required[str] - azure_ad_token: str - azure_ad_token_provider: AsyncAzureADTokenProvider - # Must be provided - model_capabilities: Required[ModelCapabilities] diff --git a/python/packages/autogen-core/src/autogen_core/components/tools/_function_tool.py b/python/packages/autogen-core/src/autogen_core/components/tools/_function_tool.py index 462374116d04..cc9145fab1e1 100644 --- a/python/packages/autogen-core/src/autogen_core/components/tools/_function_tool.py +++ b/python/packages/autogen-core/src/autogen_core/components/tools/_function_tool.py @@ -41,6 +41,7 @@ class FunctionTool(BaseTool[BaseModel, BaseModel]): from autogen_core.base import CancellationToken from autogen_core.components.tools import FunctionTool from typing_extensions import Annotated + import asyncio async def get_stock_price(ticker: str, date: Annotated[str, "Date in YYYY/MM/DD"]) -> float: @@ -48,15 +49,19 @@ async def get_stock_price(ticker: str, date: Annotated[str, "Date in YYYY/MM/DD" return random.uniform(10, 200) - # Initialize a FunctionTool instance for retrieving stock prices. - stock_price_tool = FunctionTool(get_stock_price, description="Fetch the stock price for a given ticker.") + async def example(): + # Initialize a FunctionTool instance for retrieving stock prices. + stock_price_tool = FunctionTool(get_stock_price, description="Fetch the stock price for a given ticker.") - # Execute the tool with cancellation support. - cancellation_token = CancellationToken() - result = await stock_price_tool.run_json({"ticker": "AAPL", "date": "2021/01/01"}, cancellation_token) + # Execute the tool with cancellation support. + cancellation_token = CancellationToken() + result = await stock_price_tool.run_json({"ticker": "AAPL", "date": "2021/01/01"}, cancellation_token) - # Output the result as a formatted string. - print(stock_price_tool.return_value_as_string(result)) + # Output the result as a formatted string. + print(stock_price_tool.return_value_as_string(result)) + + + asyncio.run(example()) """ def __init__(self, func: Callable[..., Any], description: str, name: str | None = None) -> None: diff --git a/python/packages/autogen-core/tests/test_tool_agent.py b/python/packages/autogen-core/tests/test_tool_agent.py index 322fdf6b7941..6184e9c78c83 100644 --- a/python/packages/autogen-core/tests/test_tool_agent.py +++ b/python/packages/autogen-core/tests/test_tool_agent.py @@ -1,6 +1,6 @@ import asyncio import json -from typing import Any, AsyncGenerator, List +from typing import Any, AsyncGenerator, List, Mapping, Optional, Sequence, Union import pytest from autogen_core.application import SingleThreadedAgentRuntime @@ -8,9 +8,13 @@ from autogen_core.components import FunctionCall from autogen_core.components.models import ( AssistantMessage, + ChatCompletionClient, + CreateResult, FunctionExecutionResult, FunctionExecutionResultMessage, - OpenAIChatCompletionClient, + LLMMessage, + ModelCapabilities, + RequestUsage, UserMessage, ) from autogen_core.components.tool_agent import ( @@ -20,13 +24,7 @@ ToolNotFoundException, tool_agent_caller_loop, ) -from autogen_core.components.tools import FunctionTool, Tool -from openai.resources.chat.completions import AsyncCompletions -from openai.types.chat.chat_completion import ChatCompletion, Choice -from openai.types.chat.chat_completion_chunk import ChatCompletionChunk -from openai.types.chat.chat_completion_message import ChatCompletionMessage -from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall, Function -from openai.types.completion_usage import CompletionUsage +from autogen_core.components.tools import FunctionTool, Tool, ToolSchema def _pass_function(input: str) -> str: @@ -42,60 +40,6 @@ async def _async_sleep_function(input: str) -> str: return "pass" -class _MockChatCompletion: - def __init__(self, model: str = "gpt-4o") -> None: - self._saved_chat_completions: List[ChatCompletion] = [ - ChatCompletion( - id="id1", - choices=[ - Choice( - finish_reason="tool_calls", - index=0, - message=ChatCompletionMessage( - content=None, - tool_calls=[ - ChatCompletionMessageToolCall( - id="1", - type="function", - function=Function( - name="pass", - arguments=json.dumps({"input": "pass"}), - ), - ) - ], - role="assistant", - ), - ) - ], - created=0, - model=model, - object="chat.completion", - usage=CompletionUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0), - ), - ChatCompletion( - id="id2", - choices=[ - Choice( - finish_reason="stop", index=0, message=ChatCompletionMessage(content="Hello", role="assistant") - ) - ], - created=0, - model=model, - object="chat.completion", - usage=CompletionUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0), - ), - ] - self._curr_index = 0 - - async def mock_create( - self, *args: Any, **kwargs: Any - ) -> ChatCompletion | AsyncGenerator[ChatCompletionChunk, None]: - await asyncio.sleep(0.1) - completion = self._saved_chat_completions[self._curr_index] - self._curr_index += 1 - return completion - - @pytest.mark.asyncio async def test_tool_agent() -> None: runtime = SingleThreadedAgentRuntime() @@ -144,10 +88,59 @@ async def test_tool_agent() -> None: @pytest.mark.asyncio -async def test_caller_loop(monkeypatch: pytest.MonkeyPatch) -> None: - mock = _MockChatCompletion(model="gpt-4o-2024-05-13") - monkeypatch.setattr(AsyncCompletions, "create", mock.mock_create) - client = OpenAIChatCompletionClient(model="gpt-4o-2024-05-13", api_key="api_key") +async def test_caller_loop() -> None: + class MockChatCompletionClient(ChatCompletionClient): + async def create( + self, + messages: Sequence[LLMMessage], + tools: Sequence[Tool | ToolSchema] = [], + json_output: Optional[bool] = None, + extra_create_args: Mapping[str, Any] = {}, + cancellation_token: Optional[CancellationToken] = None, + ) -> CreateResult: + if len(messages) == 1: + return CreateResult( + content=[FunctionCall(id="1", name="pass", arguments=json.dumps({"input": "test"}))], + finish_reason="stop", + usage=RequestUsage(prompt_tokens=0, completion_tokens=0), + cached=False, + logprobs=None, + ) + return CreateResult( + content="Done", + finish_reason="stop", + usage=RequestUsage(prompt_tokens=0, completion_tokens=0), + cached=False, + logprobs=None, + ) + + def create_stream( + self, + messages: Sequence[LLMMessage], + tools: Sequence[Tool | ToolSchema] = [], + json_output: Optional[bool] = None, + extra_create_args: Mapping[str, Any] = {}, + cancellation_token: Optional[CancellationToken] = None, + ) -> AsyncGenerator[Union[str, CreateResult], None]: + raise NotImplementedError() + + def actual_usage(self) -> RequestUsage: + return RequestUsage(prompt_tokens=0, completion_tokens=0) + + def total_usage(self) -> RequestUsage: + return RequestUsage(prompt_tokens=0, completion_tokens=0) + + def count_tokens(self, messages: Sequence[LLMMessage], tools: Sequence[Tool | ToolSchema] = []) -> int: + return 0 + + def remaining_tokens(self, messages: Sequence[LLMMessage], tools: Sequence[Tool | ToolSchema] = []) -> int: + return 0 + + @property + def capabilities(self) -> ModelCapabilities: + return ModelCapabilities(vision=False, function_calling=True, json_output=False) + + client = MockChatCompletionClient() tools: List[Tool] = [FunctionTool(_pass_function, name="pass", description="Pass function")] runtime = SingleThreadedAgentRuntime() await runtime.register( diff --git a/python/packages/autogen-core/tests/test_tools.py b/python/packages/autogen-core/tests/test_tools.py index 70ec08469706..e7995969802f 100644 --- a/python/packages/autogen-core/tests/test_tools.py +++ b/python/packages/autogen-core/tests/test_tools.py @@ -4,7 +4,6 @@ import pytest from autogen_core.base import CancellationToken from autogen_core.components._function_utils import get_typed_signature -from autogen_core.components.models._openai_client import convert_tools from autogen_core.components.tools import BaseTool, FunctionTool from autogen_core.components.tools._base import ToolSchema from pydantic import BaseModel, Field, model_serializer @@ -142,7 +141,7 @@ def my_function() -> str: sig = get_typed_signature(my_function) assert isinstance(sig, inspect.Signature) assert len(sig.parameters) == 0 - assert sig.return_annotation == str + assert sig.return_annotation is str def test_get_typed_signature_annotated() -> None: @@ -162,7 +161,7 @@ def my_function() -> "str": sig = get_typed_signature(my_function) assert isinstance(sig, inspect.Signature) assert len(sig.parameters) == 0 - assert sig.return_annotation == str + assert sig.return_annotation is str def test_func_tool() -> None: @@ -187,11 +186,11 @@ def my_function(my_arg: Annotated[str, "test description"]) -> str: assert issubclass(tool.args_type(), BaseModel) assert issubclass(tool.return_type(), str) assert tool.args_type().model_fields["my_arg"].description == "test description" - assert tool.args_type().model_fields["my_arg"].annotation == str + assert tool.args_type().model_fields["my_arg"].annotation is str assert tool.args_type().model_fields["my_arg"].is_required() is True assert tool.args_type().model_fields["my_arg"].default is PydanticUndefined assert len(tool.args_type().model_fields) == 1 - assert tool.return_type() == str + assert tool.return_type() is str assert tool.state_type() is None @@ -203,7 +202,7 @@ def my_function() -> Annotated[str, "test description"]: assert tool.name == "my_function" assert tool.description == "Function tool." assert issubclass(tool.args_type(), BaseModel) - assert tool.return_type() == str + assert tool.return_type() is str assert tool.state_type() is None @@ -216,7 +215,7 @@ def my_function() -> str: assert tool.description == "Function tool." assert issubclass(tool.args_type(), BaseModel) assert len(tool.args_type().model_fields) == 0 - assert tool.return_type() == str + assert tool.return_type() is str assert tool.state_type() is None @@ -323,29 +322,6 @@ def my_function(arg: int) -> int: assert tool.return_value_as_string(result) == "5" -def test_convert_tools_accepts_both_func_tool_and_schema() -> None: - def my_function(arg: str, other: Annotated[int, "int arg"], nonrequired: int = 5) -> MyResult: - return MyResult(result="test") - - tool = FunctionTool(my_function, description="Function tool.") - schema = tool.schema - - converted_tool_schema = convert_tools([tool, schema]) - - assert len(converted_tool_schema) == 2 - assert converted_tool_schema[0] == converted_tool_schema[1] - - -def test_convert_tools_accepts_both_tool_and_schema() -> None: - tool = MyTool() - schema = tool.schema - - converted_tool_schema = convert_tools([tool, schema]) - - assert len(converted_tool_schema) == 2 - assert converted_tool_schema[0] == converted_tool_schema[1] - - @pytest.mark.asyncio async def test_func_tool_return_list() -> None: def my_function() -> List[int]: diff --git a/python/packages/autogen-ext/pyproject.toml b/python/packages/autogen-ext/pyproject.toml index f9d741842d7f..3d263df51a50 100644 --- a/python/packages/autogen-ext/pyproject.toml +++ b/python/packages/autogen-ext/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "autogen-ext" -version = "0.4.0.dev6" +version = "0.4.0.dev7" license = {file = "LICENSE-CODE"} description = "AutoGen extensions library" readme = "README.md" @@ -15,7 +15,7 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ - "autogen-core==0.4.0.dev6", + "autogen-core==0.4.0.dev7", ] diff --git a/python/packages/autogen-ext/src/autogen_ext/agents/__init__.py b/python/packages/autogen-ext/src/autogen_ext/agents/__init__.py index d89a890ab2bf..d595e0180e6c 100644 --- a/python/packages/autogen-ext/src/autogen_ext/agents/__init__.py +++ b/python/packages/autogen-ext/src/autogen_ext/agents/__init__.py @@ -1,3 +1,4 @@ +from ._openai_assistant_agent import OpenAIAssistantAgent from .web_surfer._multimodal_web_surfer import MultimodalWebSurfer -__all__ = ["MultimodalWebSurfer"] +__all__ = ["MultimodalWebSurfer", "OpenAIAssistantAgent"] diff --git a/python/packages/autogen-ext/src/autogen_ext/agents/_openai_assistant_agent.py b/python/packages/autogen-ext/src/autogen_ext/agents/_openai_assistant_agent.py new file mode 100644 index 000000000000..7e1124728fbf --- /dev/null +++ b/python/packages/autogen-ext/src/autogen_ext/agents/_openai_assistant_agent.py @@ -0,0 +1,562 @@ +import asyncio +import json +import logging +import os +from typing import ( + Any, + AsyncGenerator, + Awaitable, + Callable, + Dict, + Iterable, + List, + Literal, + Optional, + Sequence, + Union, + cast, +) + +import aiofiles +from autogen_agentchat import EVENT_LOGGER_NAME +from autogen_agentchat.agents import BaseChatAgent +from autogen_agentchat.base import Response +from autogen_agentchat.messages import ( + AgentMessage, + ChatMessage, + HandoffMessage, + MultiModalMessage, + StopMessage, + TextMessage, + ToolCallMessage, + ToolCallResultMessage, +) +from autogen_core.base import CancellationToken +from autogen_core.components import FunctionCall +from autogen_core.components.models._types import FunctionExecutionResult +from autogen_core.components.tools import FunctionTool, Tool +from openai import NOT_GIVEN, AsyncClient, NotGiven +from openai.pagination import AsyncCursorPage +from openai.resources.beta.threads import AsyncMessages, AsyncRuns, AsyncThreads +from openai.types import FileObject +from openai.types.beta import thread_update_params +from openai.types.beta.assistant import Assistant +from openai.types.beta.assistant_response_format_option_param import AssistantResponseFormatOptionParam +from openai.types.beta.assistant_tool_param import AssistantToolParam +from openai.types.beta.code_interpreter_tool_param import CodeInterpreterToolParam +from openai.types.beta.file_search_tool_param import FileSearchToolParam +from openai.types.beta.function_tool_param import FunctionToolParam +from openai.types.beta.thread import Thread, ToolResources, ToolResourcesCodeInterpreter +from openai.types.beta.threads import Message, MessageDeleted, Run +from openai.types.beta.vector_store import VectorStore +from openai.types.shared_params.function_definition import FunctionDefinition + +event_logger = logging.getLogger(EVENT_LOGGER_NAME) + + +def _convert_tool_to_function_param(tool: Tool) -> FunctionToolParam: + """Convert an autogen Tool to an OpenAI Assistant function tool parameter.""" + schema = tool.schema + parameters: Dict[str, object] = {} + if "parameters" in schema: + parameters = { + "type": schema["parameters"]["type"], + "properties": schema["parameters"]["properties"], + } + if "required" in schema["parameters"]: + parameters["required"] = schema["parameters"]["required"] + + function_def = FunctionDefinition( + name=schema["name"], + description=schema.get("description", ""), + parameters=parameters, + ) + return FunctionToolParam(type="function", function=function_def) + + +class OpenAIAssistantAgent(BaseChatAgent): + """An agent implementation that uses the OpenAI Assistant API to generate responses. + + This agent leverages the OpenAI Assistant API to create AI assistants with capabilities like: + - Code interpretation and execution + - File handling and search + - Custom function calling + - Multi-turn conversations + + The agent maintains a thread of conversation and can use various tools including: + - Code interpreter: For executing code and working with files + - File search: For searching through uploaded documents + - Custom functions: For extending capabilities with user-defined tools + + .. note:: + + The agent deletes all messages in the thread when :meth:`on_reset` is called. + + Key Features: + - Supports multiple file formats including code, documents, images + - Can handle up to 128 tools per assistant + - Maintains conversation context in threads + - Supports file uploads for code interpreter and search + - Vector store integration for efficient file search + - Automatic file parsing and embedding + + Example: + .. code-block:: python + + from openai import AsyncClient + from autogen_core.base import CancellationToken + import asyncio + from autogen_ext.agents import OpenAIAssistantAgent + from autogen_agentchat.messages import TextMessage + + + async def example(): + cancellation_token = CancellationToken() + + # Create an OpenAI client + client = AsyncClient(api_key="your-api-key", base_url="your-base-url") + + # Create an assistant with code interpreter + assistant = OpenAIAssistantAgent( + name="Python Helper", + description="Helps with Python programming", + client=client, + model="gpt-4", + instructions="You are a helpful Python programming assistant.", + tools=["code_interpreter"], + ) + + # Upload files for the assistant to use + await assistant.on_upload_for_code_interpreter("data.csv", cancellation_token) + + # Get response from the assistant + _response = await assistant.on_messages( + [TextMessage(source="user", content="Analyze the data in data.csv")], cancellation_token + ) + + # Clean up resources + await assistant.delete_uploaded_files(cancellation_token) + await assistant.delete_assistant(cancellation_token) + + + asyncio.run(example()) + + Args: + name (str): Name of the assistant + description (str): Description of the assistant's purpose + client (AsyncClient): OpenAI API client instance + model (str): Model to use (e.g. "gpt-4") + instructions (str): System instructions for the assistant + tools (Optional[Iterable[Union[Literal["code_interpreter", "file_search"], Tool | Callable[..., Any] | Callable[..., Awaitable[Any]]]]]): Tools the assistant can use + assistant_id (Optional[str]): ID of existing assistant to use + metadata (Optional[object]): Additional metadata for the assistant + response_format (Optional[AssistantResponseFormatOptionParam]): Response format settings + temperature (Optional[float]): Temperature for response generation + tool_resources (Optional[ToolResources]): Additional tool configuration + top_p (Optional[float]): Top p sampling parameter + """ + + def __init__( + self, + name: str, + description: str, + client: AsyncClient, + model: str, + instructions: str, + tools: Optional[ + Iterable[ + Union[ + Literal["code_interpreter", "file_search"], + Tool | Callable[..., Any] | Callable[..., Awaitable[Any]], + ] + ] + ] = None, + assistant_id: Optional[str] = None, + thread_id: Optional[str] = None, + metadata: Optional[object] = None, + response_format: Optional[AssistantResponseFormatOptionParam] = None, + temperature: Optional[float] = None, + tool_resources: Optional[ToolResources] = None, + top_p: Optional[float] = None, + ) -> None: + super().__init__(name, description) + if tools is None: + tools = [] + + # Store original tools and converted tools separately + self._original_tools: List[Tool] = [] + converted_tools: List[AssistantToolParam] = [] + for tool in tools: + if isinstance(tool, str): + if tool == "code_interpreter": + converted_tools.append(CodeInterpreterToolParam(type="code_interpreter")) + elif tool == "file_search": + converted_tools.append(FileSearchToolParam(type="file_search")) + elif isinstance(tool, Tool): + self._original_tools.append(tool) + converted_tools.append(_convert_tool_to_function_param(tool)) + elif callable(tool): + if hasattr(tool, "__doc__") and tool.__doc__ is not None: + description = tool.__doc__ + else: + description = "" + function_tool = FunctionTool(tool, description=description) + self._original_tools.append(function_tool) + converted_tools.append(_convert_tool_to_function_param(function_tool)) + else: + raise ValueError(f"Unsupported tool type: {type(tool)}") + + self._client = client + self._assistant: Optional[Assistant] = None + self._thread: Optional[Thread] = None + self._init_thread_id = thread_id + self._model = model + self._instructions = instructions + self._api_tools = converted_tools + self._assistant_id = assistant_id + self._metadata = metadata + self._response_format = response_format + self._temperature = temperature + self._tool_resources = tool_resources + self._top_p = top_p + self._vector_store_id: Optional[str] = None + self._uploaded_file_ids: List[str] = [] + + async def _ensure_initialized(self) -> None: + """Ensure assistant and thread are created.""" + if self._assistant is None: + if self._assistant_id: + self._assistant = await self._client.beta.assistants.retrieve(assistant_id=self._assistant_id) + else: + self._assistant = await self._client.beta.assistants.create( + model=self._model, + description=self.description, + instructions=self._instructions, + tools=self._api_tools, + metadata=self._metadata, + response_format=self._response_format if self._response_format else NOT_GIVEN, # type: ignore + temperature=self._temperature, + tool_resources=self._tool_resources if self._tool_resources else NOT_GIVEN, # type: ignore + top_p=self._top_p, + ) + + if self._thread is None: + if self._init_thread_id: + self._thread = await self._client.beta.threads.retrieve(thread_id=self._init_thread_id) + else: + self._thread = await self._client.beta.threads.create() + + @property + def produced_message_types(self) -> List[type[ChatMessage]]: + """The types of messages that the assistant agent produces.""" + return [TextMessage] + + @property + def threads(self) -> AsyncThreads: + return self._client.beta.threads + + @property + def runs(self) -> AsyncRuns: + return self._client.beta.threads.runs + + @property + def messages(self) -> AsyncMessages: + return self._client.beta.threads.messages + + @property + def _get_assistant_id(self) -> str: + if self._assistant is None: + raise ValueError("Assistant not initialized") + return self._assistant.id + + @property + def _thread_id(self) -> str: + if self._thread is None: + raise ValueError("Thread not initialized") + return self._thread.id + + async def _execute_tool_call(self, tool_call: FunctionCall, cancellation_token: CancellationToken) -> str: + """Execute a tool call and return the result.""" + try: + if not self._original_tools: + raise ValueError("No tools are available.") + tool = next((t for t in self._original_tools if t.name == tool_call.name), None) + if tool is None: + raise ValueError(f"The tool '{tool_call.name}' is not available.") + arguments = json.loads(tool_call.arguments) + result = await tool.run_json(arguments, cancellation_token) + return tool.return_value_as_string(result) + except Exception as e: + return f"Error: {e}" + + async def on_messages(self, messages: Sequence[ChatMessage], cancellation_token: CancellationToken) -> Response: + """Handle incoming messages and return a response.""" + async for message in self.on_messages_stream(messages, cancellation_token): + if isinstance(message, Response): + return message + raise AssertionError("The stream should have returned the final result.") + + async def on_messages_stream( + self, messages: Sequence[ChatMessage], cancellation_token: CancellationToken + ) -> AsyncGenerator[AgentMessage | Response, None]: + """Handle incoming messages and return a response.""" + await self._ensure_initialized() + + # Process all messages in sequence + for message in messages: + if isinstance(message, (TextMessage, MultiModalMessage)): + await self.handle_text_message(str(message.content), cancellation_token) + elif isinstance(message, (StopMessage, HandoffMessage)): + await self.handle_text_message(message.content, cancellation_token) + + # Inner messages for tool calls + inner_messages: List[AgentMessage] = [] + + # Create and start a run + run: Run = await cancellation_token.link_future( + asyncio.ensure_future( + self._client.beta.threads.runs.create( + thread_id=self._thread_id, + assistant_id=self._get_assistant_id, + ) + ) + ) + + # Wait for run completion by polling + while True: + run = await cancellation_token.link_future( + asyncio.ensure_future( + self._client.beta.threads.runs.retrieve( + thread_id=self._thread_id, + run_id=run.id, + ) + ) + ) + + if run.status == "failed": + raise ValueError(f"Run failed: {run.last_error}") + + # If the run requires action (function calls), execute tools and continue + if run.status == "requires_action" and run.required_action is not None: + tool_calls: List[FunctionCall] = [] + for required_tool_call in run.required_action.submit_tool_outputs.tool_calls: + if required_tool_call.type == "function": + tool_calls.append( + FunctionCall( + id=required_tool_call.id, + name=required_tool_call.function.name, + arguments=required_tool_call.function.arguments, + ) + ) + + # Add tool call message to inner messages + tool_call_msg = ToolCallMessage(source=self.name, content=tool_calls) + inner_messages.append(tool_call_msg) + event_logger.debug(tool_call_msg) + yield tool_call_msg + + # Execute tool calls and get results + tool_outputs: List[FunctionExecutionResult] = [] + for tool_call in tool_calls: + result = await self._execute_tool_call(tool_call, cancellation_token) + tool_outputs.append(FunctionExecutionResult(content=result, call_id=tool_call.id)) + + # Add tool result message to inner messages + tool_result_msg = ToolCallResultMessage(source=self.name, content=tool_outputs) + inner_messages.append(tool_result_msg) + event_logger.debug(tool_result_msg) + yield tool_result_msg + + # Submit tool outputs back to the run + run = await cancellation_token.link_future( + asyncio.ensure_future( + self._client.beta.threads.runs.submit_tool_outputs( + thread_id=self._thread_id, + run_id=run.id, + tool_outputs=[{"tool_call_id": t.call_id, "output": t.content} for t in tool_outputs], + ) + ) + ) + continue + + if run.status == "completed": + break + + await asyncio.sleep(0.5) + + # Get messages after run completion + assistant_messages: AsyncCursorPage[Message] = await cancellation_token.link_future( + asyncio.ensure_future( + self._client.beta.threads.messages.list(thread_id=self._thread_id, order="desc", limit=1) + ) + ) + + if not assistant_messages.data: + raise ValueError("No messages received from assistant") + + # Get the last message's content + last_message = assistant_messages.data[0] + if not last_message.content: + raise ValueError(f"No content in the last message: {last_message}") + + # Extract text content + text_content = [content for content in last_message.content if content.type == "text"] + if not text_content: + raise ValueError(f"Expected text content in the last message: {last_message.content}") + + # Return the assistant's response as a Response with inner messages + chat_message = TextMessage(source=self.name, content=text_content[0].text.value) + yield Response(chat_message=chat_message, inner_messages=inner_messages) + + async def handle_text_message(self, content: str, cancellation_token: CancellationToken) -> None: + """Handle regular text messages by adding them to the thread.""" + await cancellation_token.link_future( + asyncio.ensure_future( + self._client.beta.threads.messages.create( + thread_id=self._thread_id, + content=content, + role="user", + ) + ) + ) + + async def on_reset(self, cancellation_token: CancellationToken) -> None: + """Handle reset command by deleting all messages in the thread.""" + # Retrieve all message IDs in the thread + all_msgs: List[str] = [] + after: str | NotGiven = NOT_GIVEN + while True: + msgs: AsyncCursorPage[Message] = await cancellation_token.link_future( + asyncio.ensure_future(self._client.beta.threads.messages.list(self._thread_id, after=after)) + ) + for msg in msgs.data: + all_msgs.append(msg.id) + after = msg.id + if not msgs.has_next_page(): + break + + # Delete all messages + for msg_id in all_msgs: + status: MessageDeleted = await cancellation_token.link_future( + asyncio.ensure_future( + self._client.beta.threads.messages.delete(message_id=msg_id, thread_id=self._thread_id) + ) + ) + assert status.deleted is True + + async def _upload_files(self, file_paths: str | Iterable[str], cancellation_token: CancellationToken) -> List[str]: + """Upload files and return their IDs.""" + if isinstance(file_paths, str): + file_paths = [file_paths] + + file_ids: List[str] = [] + for file_path in file_paths: + async with aiofiles.open(file_path, mode="rb") as f: + file_content = await cancellation_token.link_future(asyncio.ensure_future(f.read())) + file_name = os.path.basename(file_path) + + file: FileObject = await cancellation_token.link_future( + asyncio.ensure_future(self._client.files.create(file=(file_name, file_content), purpose="assistants")) + ) + file_ids.append(file.id) + self._uploaded_file_ids.append(file.id) + + return file_ids + + async def on_upload_for_code_interpreter( + self, file_paths: str | Iterable[str], cancellation_token: CancellationToken + ) -> None: + """Handle file uploads for the code interpreter.""" + file_ids = await self._upload_files(file_paths, cancellation_token) + + # Update thread with the new files + thread = await cancellation_token.link_future( + asyncio.ensure_future(self._client.beta.threads.retrieve(thread_id=self._thread_id)) + ) + tool_resources: ToolResources = thread.tool_resources or ToolResources() + code_interpreter: ToolResourcesCodeInterpreter = ( + tool_resources.code_interpreter or ToolResourcesCodeInterpreter() + ) + existing_file_ids: List[str] = code_interpreter.file_ids or [] + existing_file_ids.extend(file_ids) + tool_resources.code_interpreter = ToolResourcesCodeInterpreter(file_ids=existing_file_ids) + + await cancellation_token.link_future( + asyncio.ensure_future( + self._client.beta.threads.update( + thread_id=self._thread_id, + tool_resources=cast(thread_update_params.ToolResources, tool_resources.model_dump()), + ) + ) + ) + + async def on_upload_for_file_search( + self, file_paths: str | Iterable[str], cancellation_token: CancellationToken + ) -> None: + """Handle file uploads for file search.""" + await self._ensure_initialized() + + # Check if file_search is enabled in tools + if not any(tool.get("type") == "file_search" for tool in self._api_tools): + raise ValueError( + "File search is not enabled for this assistant. Add a file_search tool when creating the assistant." + ) + + # Create vector store if not already created + if self._vector_store_id is None: + vector_store: VectorStore = await cancellation_token.link_future( + asyncio.ensure_future(self._client.beta.vector_stores.create()) + ) + self._vector_store_id = vector_store.id + + # Update assistant with vector store ID + await cancellation_token.link_future( + asyncio.ensure_future( + self._client.beta.assistants.update( + assistant_id=self._get_assistant_id, + tool_resources={"file_search": {"vector_store_ids": [self._vector_store_id]}}, + ) + ) + ) + + file_ids = await self._upload_files(file_paths, cancellation_token) + + # Create file batch with the file IDs + await cancellation_token.link_future( + asyncio.ensure_future( + self._client.beta.vector_stores.file_batches.create_and_poll( + vector_store_id=self._vector_store_id, file_ids=file_ids + ) + ) + ) + + async def delete_uploaded_files(self, cancellation_token: CancellationToken) -> None: + """Delete all files that were uploaded by this agent instance.""" + for file_id in self._uploaded_file_ids: + try: + await cancellation_token.link_future(asyncio.ensure_future(self._client.files.delete(file_id=file_id))) + except Exception as e: + event_logger.error(f"Failed to delete file {file_id}: {str(e)}") + self._uploaded_file_ids = [] + + async def delete_assistant(self, cancellation_token: CancellationToken) -> None: + """Delete the assistant if it was created by this instance.""" + if self._assistant is not None and not self._assistant_id: + try: + await cancellation_token.link_future( + asyncio.ensure_future(self._client.beta.assistants.delete(assistant_id=self._get_assistant_id)) + ) + self._assistant = None + except Exception as e: + event_logger.error(f"Failed to delete assistant: {str(e)}") + + async def delete_vector_store(self, cancellation_token: CancellationToken) -> None: + """Delete the vector store if it was created by this instance.""" + if self._vector_store_id is not None: + try: + await cancellation_token.link_future( + asyncio.ensure_future(self._client.beta.vector_stores.delete(vector_store_id=self._vector_store_id)) + ) + self._vector_store_id = None + except Exception as e: + event_logger.error(f"Failed to delete vector store: {str(e)}") diff --git a/python/packages/autogen-ext/src/autogen_ext/code_executor/aca_dynamic_sessions/__init__.py b/python/packages/autogen-ext/src/autogen_ext/code_executor/aca_dynamic_sessions/__init__.py deleted file mode 100644 index 009997c41abc..000000000000 --- a/python/packages/autogen-ext/src/autogen_ext/code_executor/aca_dynamic_sessions/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -import warnings -from typing import Any - -from ...code_executors import ACADynamicSessionsCodeExecutor - - -class AzureContainerCodeExecutor(ACADynamicSessionsCodeExecutor): - """AzureContainerCodeExecutor has been renamed and moved to autogen_ext.code_executors.ACADynamicSessionsCodeExecutor""" - - def __init__(self, *args: Any, **kwargs: Any) -> None: - warnings.warn( - "AzureContainerCodeExecutor has been renamed and moved to autogen_ext.code_executors.ACADynamicSessionsCodeExecutor", - DeprecationWarning, - stacklevel=2, - ) - super().__init__(*args, **kwargs) - - -__all__ = [ - "AzureContainerCodeExecutor", -] diff --git a/python/packages/autogen-ext/src/autogen_ext/code_executor/docker_executor/__init__.py b/python/packages/autogen-ext/src/autogen_ext/code_executor/docker_executor/__init__.py deleted file mode 100644 index 66719114300d..000000000000 --- a/python/packages/autogen-ext/src/autogen_ext/code_executor/docker_executor/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -import warnings - -from ...code_executors import DockerCommandLineCodeExecutor - -warnings.warn( - "DockerCommandLineCodeExecutor moved to autogen_ext.code_executors.DockerCommandLineCodeExecutor", - DeprecationWarning, - stacklevel=2, -) - -__all__ = ["DockerCommandLineCodeExecutor"] diff --git a/python/packages/autogen-ext/src/autogen_ext/models/__init__.py b/python/packages/autogen-ext/src/autogen_ext/models/__init__.py index d39c1d9bf247..80533f80575e 100644 --- a/python/packages/autogen-ext/src/autogen_ext/models/__init__.py +++ b/python/packages/autogen-ext/src/autogen_ext/models/__init__.py @@ -2,6 +2,13 @@ AzureOpenAIChatCompletionClient, OpenAIChatCompletionClient, ) +from ._openai.config import AzureOpenAIClientConfiguration, OpenAIClientConfiguration from ._reply_chat_completion_client import ReplayChatCompletionClient -__all__ = ["AzureOpenAIChatCompletionClient", "OpenAIChatCompletionClient", "ReplayChatCompletionClient"] +__all__ = [ + "AzureOpenAIClientConfiguration", + "AzureOpenAIChatCompletionClient", + "OpenAIClientConfiguration", + "OpenAIChatCompletionClient", + "ReplayChatCompletionClient", +] diff --git a/python/packages/autogen-ext/src/autogen_ext/models/_openai/_openai_client.py b/python/packages/autogen-ext/src/autogen_ext/models/_openai/_openai_client.py index aa9f772d0b95..b00ba32da559 100644 --- a/python/packages/autogen-ext/src/autogen_ext/models/_openai/_openai_client.py +++ b/python/packages/autogen-ext/src/autogen_ext/models/_openai/_openai_client.py @@ -86,10 +86,29 @@ def _azure_openai_client_from_config(config: Mapping[str, Any]) -> AsyncAzureOpe # Take a copy copied_config = dict(config).copy() + import warnings + + if "azure_deployment" not in copied_config and "model" in copied_config: + warnings.warn( + "Previous behavior of using the model name as the deployment name is deprecated and will be removed in 0.4. Please specify azure_deployment", + stacklevel=2, + ) + + if "azure_endpoint" not in copied_config and "base_url" in copied_config: + warnings.warn( + "Previous behavior of using the base_url as the endpoint is deprecated and will be removed in 0.4. Please specify azure_endpoint", + stacklevel=2, + ) + # Do some fixups copied_config["azure_deployment"] = copied_config.get("azure_deployment", config.get("model")) if copied_config["azure_deployment"] is not None: - copied_config["azure_deployment"] = copied_config["azure_deployment"].replace(".", "") + if "." in copied_config["azure_deployment"]: + warnings.warn( + "Previous behavior stripping '.' from the deployment name is deprecated and will be removed in 0.4", + stacklevel=2, + ) + copied_config["azure_deployment"] = copied_config["azure_deployment"].replace(".", "") copied_config["azure_endpoint"] = copied_config.get("azure_endpoint", copied_config.pop("base_url", None)) # Shave down the config to just the AzureOpenAIChatCompletionClient kwargs @@ -331,9 +350,7 @@ def __init__( model_capabilities: Optional[ModelCapabilities] = None, ): self._client = client - if model_capabilities is None and isinstance(client, AsyncAzureOpenAI): - raise ValueError("AzureOpenAIChatCompletionClient requires explicit model capabilities") - elif model_capabilities is None: + if model_capabilities is None: self._model_capabilities = _model_info.get_capabilities(create_args["model"]) else: self._model_capabilities = model_capabilities @@ -556,6 +573,8 @@ async def create_stream( json_output: Optional[bool] = None, extra_create_args: Mapping[str, Any] = {}, cancellation_token: Optional[CancellationToken] = None, + *, + max_consecutive_empty_chunk_tolerance: int = 0, ) -> AsyncGenerator[Union[str, CreateResult], None]: """ Creates an AsyncGenerator that will yield a stream of chat completions based on the provided messages and tools. @@ -566,6 +585,7 @@ async def create_stream( json_output (Optional[bool], optional): If True, the output will be in JSON format. Defaults to None. extra_create_args (Mapping[str, Any], optional): Additional arguments for the creation process. Default to `{}`. cancellation_token (Optional[CancellationToken], optional): A token to cancel the operation. Defaults to None. + max_consecutive_empty_chunk_tolerance (int): The maximum number of consecutive empty chunks to tolerate before raising a ValueError. This seems to only be needed to set when using `AzureOpenAIChatCompletionClient`. Defaults to 0. Yields: AsyncGenerator[Union[str, CreateResult], None]: A generator yielding the completion results as they are produced. @@ -636,6 +656,8 @@ async def create_stream( full_tool_calls: Dict[int, FunctionCall] = {} completion_tokens = 0 logprobs: Optional[List[ChatCompletionTokenLogprob]] = None + empty_chunk_count = 0 + while True: try: chunk_future = asyncio.ensure_future(anext(stream)) @@ -643,6 +665,20 @@ async def create_stream( cancellation_token.link_future(chunk_future) chunk = await chunk_future + # This is to address a bug in AzureOpenAIChatCompletionClient. OpenAIChatCompletionClient works fine. + # https://github.com/microsoft/autogen/issues/4213 + if len(chunk.choices) == 0: + empty_chunk_count += 1 + if max_consecutive_empty_chunk_tolerance == 0: + raise ValueError( + "Consecutive empty chunks found. Change max_empty_consecutive_chunk_tolerance to increase empty chunk tolerance" + ) + elif empty_chunk_count >= max_consecutive_empty_chunk_tolerance: + raise ValueError("Exceeded the threshold of receiving consecutive empty chunks") + continue + else: + empty_chunk_count = 0 + # to process usage chunk in streaming situations # add stream_options={"include_usage": True} in the initialization of OpenAIChatCompletionClient(...) # However the different api's @@ -851,6 +887,64 @@ def capabilities(self) -> ModelCapabilities: class OpenAIChatCompletionClient(BaseOpenAIChatCompletionClient): + """Chat completion client for OpenAI hosted models. + + You can also use this client for OpenAI-compatible ChatCompletion endpoints. + **Using this client for non-OpenAI models is not tested or guaranteed.** + + For non-OpenAI models, please first take a look at our `community extensions `_ + for additional model clients. + + Args: + model (str): The model to use. **Required.** + api_key (str): The API key to use. **Required if 'OPENAI_API_KEY' is not found in the environment variables.** + timeout (optional, int): The timeout for the request in seconds. + max_retries (optional, int): The maximum number of retries to attempt. + organization_id (optional, str): The organization ID to use. + base_url (optional, str): The base URL to use. **Required if the model is not hosted on OpenAI.** + model_capabilities (optional, ModelCapabilities): The capabilities of the model. **Required if the model name is not a valid OpenAI model.** + + To use this client, you must install the `openai` extension: + + .. code-block:: bash + + pip install 'autogen-ext[openai]==0.4.0.dev7' + + The following code snippet shows how to use the client with an OpenAI model: + + .. code-block:: python + + from autogen_ext.models import OpenAIChatCompletionClient + from autogen_core.components.models import UserMessage + + openai_client = OpenAIChatCompletionClient( + model="gpt-4o-2024-08-06", + # api_key="sk-...", # Optional if you have an OPENAI_API_KEY environment variable set. + ) + + result = await openai_client.create([UserMessage(content="What is the capital of France?", source="user")]) # type: ignore + print(result) + + + To use the client with a non-OpenAI model, you need to provide the base URL of the model and the model capabilities: + + .. code-block:: python + + from autogen_ext.models import OpenAIChatCompletionClient + + custom_model_client = OpenAIChatCompletionClient( + model="custom-model-name", + base_url="https://custom-model.com/reset/of/the/path", + api_key="placeholder", + model_capabilities={ + "vision": True, + "function_calling": True, + "json_output": True, + }, + ) + + """ + def __init__(self, **kwargs: Unpack[OpenAIClientConfiguration]): if "model" not in kwargs: raise ValueError("model is required for OpenAIChatCompletionClient") @@ -877,10 +971,54 @@ def __setstate__(self, state: Dict[str, Any]) -> None: class AzureOpenAIChatCompletionClient(BaseOpenAIChatCompletionClient): - def __init__(self, **kwargs: Unpack[AzureOpenAIClientConfiguration]): - if "model" not in kwargs: - raise ValueError("model is required for OpenAIChatCompletionClient") + """Chat completion client for Azure OpenAI hosted models. + + Args: + azure_endpoint (str): The endpoint for the Azure model. **Required for Azure models.** + model (str): The deployment ID for the Azure model. **Required for Azure models.** + api_version (str): The API version to use. **Required for Azure models.** + azure_ad_token (str): The Azure AD token to use. Provide this or `azure_ad_token_provider` for token-based authentication. + azure_ad_token_provider (Callable[[], Awaitable[str]]): The Azure AD token provider to use. Provide this or `azure_ad_token` for token-based authentication. + model_capabilities (ModelCapabilities): The capabilities of the model if default resolved values are not correct. + api_key (optional, str): The API key to use, use this if you are using key based authentication. It is optional if you are using Azure AD token based authentication or `AZURE_OPENAI_API_KEY` environment variable. + timeout (optional, int): The timeout for the request in seconds. + max_retries (optional, int): The maximum number of retries to attempt. + + To use this client, you must install the `azure` and `openai` extensions: + + .. code-block:: bash + pip install 'autogen-ext[openai,azure]==0.4.0.dev7' + + To use the client, you need to provide your deployment id, Azure Cognitive Services endpoint, + api version, and model capabilities. + For authentication, you can either provide an API key or an Azure Active Directory (AAD) token credential. + + The following code snippet shows how to use AAD authentication. + The identity used must be assigned the `Cognitive Services OpenAI User `_ role. + + .. code-block:: python + + from autogen_ext.models import AzureOpenAIChatCompletionClient + from azure.identity import DefaultAzureCredential, get_bearer_token_provider + + # Create the token provider + token_provider = get_bearer_token_provider(DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default") + + az_model_client = AzureOpenAIChatCompletionClient( + azure_deployment="{your-azure-deployment}", + model="{deployed-model, such as 'gpt-4o'}", + api_version="2024-06-01", + azure_endpoint="https://{your-custom-endpoint}.openai.azure.com/", + azure_ad_token_provider=token_provider, # Optional if you choose key-based authentication. + # api_key="sk-...", # For key-based authentication. `AZURE_OPENAI_API_KEY` environment variable can also be used instead. + ) + + See `here `_ for how to use the Azure client directly or for more info. + + """ + + def __init__(self, **kwargs: Unpack[AzureOpenAIClientConfiguration]): model_capabilities: Optional[ModelCapabilities] = None copied_args = dict(kwargs).copy() if "model_capabilities" in kwargs: diff --git a/python/packages/autogen-ext/src/autogen_ext/models/_openai/config/__init__.py b/python/packages/autogen-ext/src/autogen_ext/models/_openai/config/__init__.py index b6729a70d11e..8afff868293e 100644 --- a/python/packages/autogen-ext/src/autogen_ext/models/_openai/config/__init__.py +++ b/python/packages/autogen-ext/src/autogen_ext/models/_openai/config/__init__.py @@ -30,14 +30,14 @@ class BaseOpenAIClientConfiguration(CreateArguments, total=False): api_key: str timeout: Union[float, None] max_retries: int + model_capabilities: ModelCapabilities + """What functionality the model supports, determined by default from model name but is overriden if value passed.""" # See OpenAI docs for explanation of these parameters class OpenAIClientConfiguration(BaseOpenAIClientConfiguration, total=False): organization: str base_url: str - # Not required - model_capabilities: ModelCapabilities class AzureOpenAIClientConfiguration(BaseOpenAIClientConfiguration, total=False): @@ -47,5 +47,6 @@ class AzureOpenAIClientConfiguration(BaseOpenAIClientConfiguration, total=False) api_version: Required[str] azure_ad_token: str azure_ad_token_provider: AsyncAzureADTokenProvider - # Must be provided - model_capabilities: Required[ModelCapabilities] + + +__all__ = ["AzureOpenAIClientConfiguration", "OpenAIClientConfiguration"] diff --git a/python/packages/autogen-ext/src/autogen_ext/models/_reply_chat_completion_client.py b/python/packages/autogen-ext/src/autogen_ext/models/_reply_chat_completion_client.py index a4889f44b415..187dfdace14a 100644 --- a/python/packages/autogen-ext/src/autogen_ext/models/_reply_chat_completion_client.py +++ b/python/packages/autogen-ext/src/autogen_ext/models/_reply_chat_completion_client.py @@ -38,50 +38,73 @@ class ReplayChatCompletionClient: .. code-block:: python - chat_completions = [ - "Hello, how can I assist you today?", - "I'm happy to help with any questions you have.", - "Is there anything else I can assist you with?", - ] - client = ReplayChatCompletionClient(chat_completions) - messages = [LLMMessage(content="What can you do?")] - response = await client.create(messages) - print(response.content) # Output: "Hello, how can I assist you today?" + from autogen_ext.models import ReplayChatCompletionClient + from autogen_core.components.models import UserMessage + + + async def example(): + chat_completions = [ + "Hello, how can I assist you today?", + "I'm happy to help with any questions you have.", + "Is there anything else I can assist you with?", + ] + client = ReplayChatCompletionClient(chat_completions) + messages = [UserMessage(content="What can you do?", source="user")] + response = await client.create(messages) + print(response.content) # Output: "Hello, how can I assist you today?" Simple streaming chat completion client to return pre-defined responses .. code-block:: python - chat_completions = [ - "Hello, how can I assist you today?", - "I'm happy to help with any questions you have.", - "Is there anything else I can assist you with?", - ] - client = ReplayChatCompletionClient(chat_completions) + import asyncio + from autogen_ext.models import ReplayChatCompletionClient + from autogen_core.components.models import UserMessage + + + async def example(): + chat_completions = [ + "Hello, how can I assist you today?", + "I'm happy to help with any questions you have.", + "Is there anything else I can assist you with?", + ] + client = ReplayChatCompletionClient(chat_completions) + messages = [UserMessage(content="What can you do?", source="user")] + + async for token in client.create_stream(messages): + print(token, end="") # Output: "Hello, how can I assist you today?" - async for token in client.create_stream(messages): - print(token, end="") # Output: "Hello, how can I assist you today?" + async for token in client.create_stream(messages): + print(token, end="") # Output: "I'm happy to help with any questions you have." - async for token in client.create_stream(messages): - print(token, end="") # Output: "I'm happy to help with any questions you have." + asyncio.run(example()) Using `.reset` to reset the chat client state .. code-block:: python - chat_completions = [ - "Hello, how can I assist you today?", - ] - client = ReplayChatCompletionClient(chat_completions) - messages = [LLMMessage(content="What can you do?")] - response = await client.create(messages) - print(response.content) # Output: "Hello, how can I assist you today?" + import asyncio + from autogen_ext.models import ReplayChatCompletionClient + from autogen_core.components.models import UserMessage + + + async def example(): + chat_completions = [ + "Hello, how can I assist you today?", + ] + client = ReplayChatCompletionClient(chat_completions) + messages = [UserMessage(content="What can you do?", source="user")] + response = await client.create(messages) + print(response.content) # Output: "Hello, how can I assist you today?" + + response = await client.create(messages) # Raises ValueError("No more mock responses available") + + client.reset() # Reset the client state (current index of message and token usages) + response = await client.create(messages) + print(response.content) # Output: "Hello, how can I assist you today?" again - response = await client.create(messages) # Raises ValueError("No more mock responses available") - client.reset() # Reset the client state (current index of message and token usages) - response = await client.create(messages) - print(response.content) # Output: "Hello, how can I assist you today?" again + asyncio.run(example()) """ diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/langchain/__init__.py b/python/packages/autogen-ext/src/autogen_ext/tools/langchain/__init__.py deleted file mode 100644 index 4d401fc7ef1f..000000000000 --- a/python/packages/autogen-ext/src/autogen_ext/tools/langchain/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -import warnings - -from ...tools import LangChainToolAdapter - -warnings.warn("LangChainToolAdapter moved to autogen_ext.tools.LangChainToolAdapter", DeprecationWarning, stacklevel=2) - -__all__ = ["LangChainToolAdapter"] diff --git a/python/packages/autogen-ext/tests/models/test_openai_model_client.py b/python/packages/autogen-ext/tests/models/test_openai_model_client.py index a51e33c0234a..b2dc504abd0e 100644 --- a/python/packages/autogen-ext/tests/models/test_openai_model_client.py +++ b/python/packages/autogen-ext/tests/models/test_openai_model_client.py @@ -1,5 +1,5 @@ import asyncio -from typing import Any, AsyncGenerator, List, Tuple +from typing import Annotated, Any, AsyncGenerator, List, Tuple from unittest.mock import MagicMock import pytest @@ -15,17 +15,25 @@ SystemMessage, UserMessage, ) -from autogen_core.components.tools import FunctionTool +from autogen_core.components.tools import BaseTool, FunctionTool from autogen_ext.models import AzureOpenAIChatCompletionClient, OpenAIChatCompletionClient from autogen_ext.models._openai._model_info import resolve_model -from autogen_ext.models._openai._openai_client import calculate_vision_tokens +from autogen_ext.models._openai._openai_client import calculate_vision_tokens, convert_tools from openai.resources.chat.completions import AsyncCompletions from openai.types.chat.chat_completion import ChatCompletion, Choice from openai.types.chat.chat_completion_chunk import ChatCompletionChunk, ChoiceDelta from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice from openai.types.chat.chat_completion_message import ChatCompletionMessage from openai.types.completion_usage import CompletionUsage -from pydantic import BaseModel +from pydantic import BaseModel, Field + + +class MyResult(BaseModel): + result: str = Field(description="The other description.") + + +class MyArgs(BaseModel): + query: str = Field(description="The description.") class MockChunkDefinition(BaseModel): @@ -133,6 +141,7 @@ async def test_openai_chat_completion_client() -> None: @pytest.mark.asyncio async def test_azure_openai_chat_completion_client() -> None: client = AzureOpenAIChatCompletionClient( + azure_deployment="gpt-4o-1", model="gpt-4o", api_key="api_key", api_version="2020-08-04", @@ -302,3 +311,38 @@ def test_openai_count_image_tokens(mock_size: Tuple[int, int], expected_num_toke # Directly call calculate_vision_tokens and check the result calculated_tokens = calculate_vision_tokens(mock_image, detail="auto") assert calculated_tokens == expected_num_tokens + + +def test_convert_tools_accepts_both_func_tool_and_schema() -> None: + def my_function(arg: str, other: Annotated[int, "int arg"], nonrequired: int = 5) -> MyResult: + return MyResult(result="test") + + tool = FunctionTool(my_function, description="Function tool.") + schema = tool.schema + + converted_tool_schema = convert_tools([tool, schema]) + + assert len(converted_tool_schema) == 2 + assert converted_tool_schema[0] == converted_tool_schema[1] + + +def test_convert_tools_accepts_both_tool_and_schema() -> None: + class MyTool(BaseTool[MyArgs, MyResult]): + def __init__(self) -> None: + super().__init__( + args_type=MyArgs, + return_type=MyResult, + name="TestTool", + description="Description of test tool.", + ) + + async def run(self, args: MyArgs, cancellation_token: CancellationToken) -> MyResult: + return MyResult(result="value") + + tool = MyTool() + schema = tool.schema + + converted_tool_schema = convert_tools([tool, schema]) + + assert len(converted_tool_schema) == 2 + assert converted_tool_schema[0] == converted_tool_schema[1] diff --git a/python/packages/autogen-ext/tests/test_openai_assistant_agent.py b/python/packages/autogen-ext/tests/test_openai_assistant_agent.py new file mode 100644 index 000000000000..3cd3e87da3e9 --- /dev/null +++ b/python/packages/autogen-ext/tests/test_openai_assistant_agent.py @@ -0,0 +1,140 @@ +import os +from enum import Enum +from typing import List, Literal, Optional, Union + +import pytest +from autogen_agentchat.messages import TextMessage +from autogen_core.base import CancellationToken +from autogen_core.components.tools._base import BaseTool, Tool +from autogen_ext.agents import OpenAIAssistantAgent +from openai import AsyncAzureOpenAI +from pydantic import BaseModel + + +class QuestionType(str, Enum): + MULTIPLE_CHOICE = "MULTIPLE_CHOICE" + FREE_RESPONSE = "FREE_RESPONSE" + + +class Question(BaseModel): + question_text: str + question_type: QuestionType + choices: Optional[List[str]] = None + + +class DisplayQuizArgs(BaseModel): + title: str + questions: List[Question] + + +class QuizResponses(BaseModel): + responses: List[str] + + +class DisplayQuizTool(BaseTool[DisplayQuizArgs, QuizResponses]): + def __init__(self) -> None: + super().__init__( + args_type=DisplayQuizArgs, + return_type=QuizResponses, + name="display_quiz", + description=( + "Displays a quiz to the student and returns the student's responses. " + "A single quiz can have multiple questions." + ), + ) + + async def run(self, args: DisplayQuizArgs, cancellation_token: CancellationToken) -> QuizResponses: + responses: List[str] = [] + for q in args.questions: + if q.question_type == QuestionType.MULTIPLE_CHOICE: + response = q.choices[0] if q.choices else "" + elif q.question_type == QuestionType.FREE_RESPONSE: + response = "Sample free response" + else: + response = "" + responses.append(response) + return QuizResponses(responses=responses) + + +@pytest.fixture +def client() -> AsyncAzureOpenAI: + azure_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT") + api_version = os.getenv("AZURE_OPENAI_API_VERSION", "2024-08-01-preview") + api_key = os.getenv("AZURE_OPENAI_API_KEY") + + if not all([azure_endpoint, api_key]): + pytest.skip("Azure OpenAI credentials not found in environment variables") + + assert azure_endpoint is not None + assert api_key is not None + return AsyncAzureOpenAI(azure_endpoint=azure_endpoint, api_version=api_version, api_key=api_key) + + +@pytest.fixture +def agent(client: AsyncAzureOpenAI) -> OpenAIAssistantAgent: + tools: List[Union[Literal["code_interpreter", "file_search"], Tool]] = [ + "code_interpreter", + "file_search", + DisplayQuizTool(), + ] + + return OpenAIAssistantAgent( + name="assistant", + instructions="Help the user with their task.", + model="gpt-4o-mini", + description="OpenAI Assistant Agent", + client=client, + tools=tools, + ) + + +@pytest.fixture +def cancellation_token() -> CancellationToken: + return CancellationToken() + + +@pytest.mark.asyncio +async def test_file_retrieval(agent: OpenAIAssistantAgent, cancellation_token: CancellationToken) -> None: + file_path = r"C:\Users\lpinheiro\Github\autogen-test\data\SampleBooks\jungle_book.txt" + await agent.on_upload_for_file_search(file_path, cancellation_token) + + message = TextMessage(source="user", content="What is the first sentence of the jungle scout book?") + response = await agent.on_messages([message], cancellation_token) + + assert response.chat_message.content is not None + assert isinstance(response.chat_message.content, str) + assert len(response.chat_message.content) > 0 + + await agent.delete_uploaded_files(cancellation_token) + await agent.delete_vector_store(cancellation_token) + await agent.delete_assistant(cancellation_token) + + +@pytest.mark.asyncio +async def test_code_interpreter(agent: OpenAIAssistantAgent, cancellation_token: CancellationToken) -> None: + message = TextMessage(source="user", content="I need to solve the equation `3x + 11 = 14`. Can you help me?") + response = await agent.on_messages([message], cancellation_token) + + assert response.chat_message.content is not None + assert isinstance(response.chat_message.content, str) + assert len(response.chat_message.content) > 0 + assert "x = 1" in response.chat_message.content.lower() + + await agent.delete_assistant(cancellation_token) + + +@pytest.mark.asyncio +async def test_quiz_creation(agent: OpenAIAssistantAgent, cancellation_token: CancellationToken) -> None: + message = TextMessage( + source="user", + content="Create a short quiz about basic math with one multiple choice question and one free response question.", + ) + response = await agent.on_messages([message], cancellation_token) + + assert response.chat_message.content is not None + assert isinstance(response.chat_message.content, str) + assert len(response.chat_message.content) > 0 + assert isinstance(response.inner_messages, list) + assert any(tool_msg.content for tool_msg in response.inner_messages if hasattr(tool_msg, "content")) + + await agent.delete_assistant(cancellation_token) diff --git a/python/packages/autogen-magentic-one/README.md b/python/packages/autogen-magentic-one/README.md index 123d3580e393..12c8498bf979 100644 --- a/python/packages/autogen-magentic-one/README.md +++ b/python/packages/autogen-magentic-one/README.md @@ -67,22 +67,24 @@ You can install the Magentic-One package and then run the example code to see ho uv sync --all-extras source .venv/bin/activate ``` - Install magentic-one from source: + For Windows, run `.venv\Scripts\activate` to activate the environment. + +2. Install magentic-one from source: ```bash cd packages/autogen-magentic-one pip install -e . ``` + + The following instructions are for running the example code: -The following instructions are for running the example code: - -2. Configure the environment variables for the chat completion client. See instructions below [Environment Configuration for Chat Completion Client](#environment-configuration-for-chat-completion-client). -3. Magentic-One code uses code execution, you need to have [Docker installed](https://docs.docker.com/engine/install/) to run any examples. -4. Magentic-One uses playwright to interact with web pages. You need to install the playwright dependencies. Run the following command to install the playwright dependencies: +3. Configure the environment variables for the chat completion client. See instructions below [Environment Configuration for Chat Completion Client](#environment-configuration-for-chat-completion-client). +4. Magentic-One code uses code execution, you need to have [Docker installed](https://docs.docker.com/engine/install/) to run any examples. +5. Magentic-One uses playwright to interact with web pages. You need to install the playwright dependencies. Run the following command to install the playwright dependencies: ```bash playwright install --with-deps chromium ``` -5. Now you can run the example code to see how the agents work together to accomplish a task. +6. Now you can run the example code to see how the agents work together to accomplish a task. > [!CAUTION] > The example code may download files from the internet, execute code, and interact with web pages. Ensure you are in a safe environment before running the example code. @@ -108,7 +110,7 @@ playwright install --with-deps chromium - hil_mode: (Optional) Enable human-in-the-loop mode (default: disabled) - save_screenshots: (Optional) Save screenshots of browser (default: disabled) -6. [Preview] We have a preview API for Magentic-One. +7. [Preview] We have a preview API for Magentic-One. You can use the `MagenticOneHelper` class to interact with the system and stream logs. See the [interface README](interface/README.md) for more details. @@ -139,6 +141,12 @@ To configure for Azure OpenAI service, set the following environment variables: } ``` +This project uses Azure OpenAI service with [Entra ID authentcation by default](https://learn.microsoft.com/azure/ai-services/openai/how-to/managed-identity). If you run the examples on a local device, you can use the Azure CLI cached credentials for testing: + +Log in to Azure using `az login`, and then run the examples. The account used must have [RBAC permissions](https://learn.microsoft.com/azure/ai-services/openai/how-to/role-based-access-control) like `Azure Cognitive Services OpenAI User` for the OpenAI service; otherwise, you will receive the error: Principal does not have access to API/Operation. + +Note that even if you are the owner of the subscription, you still need to grant the necessary Azure Cognitive Services OpenAI permissions to call the API. + ### With OpenAI To configure for OpenAI, set the following environment variables: diff --git a/python/packages/autogen-studio/autogenstudio/teammanager.py b/python/packages/autogen-studio/autogenstudio/teammanager.py index 8b4b8c8be4a3..76d202f64a59 100644 --- a/python/packages/autogen-studio/autogenstudio/teammanager.py +++ b/python/packages/autogen-studio/autogenstudio/teammanager.py @@ -33,7 +33,8 @@ async def run_stream( try: team = await self._create_team(team_config, input_func) - stream = team.run_stream(task=task, cancellation_token=cancellation_token) + stream = team.run_stream( + task=task, cancellation_token=cancellation_token) async for message in stream: if cancellation_token and cancellation_token.is_cancelled(): diff --git a/python/packages/autogen-studio/autogenstudio/web/managers/connection.py b/python/packages/autogen-studio/autogenstudio/web/managers/connection.py index a63eec331dfb..16684ad32ba8 100644 --- a/python/packages/autogen-studio/autogenstudio/web/managers/connection.py +++ b/python/packages/autogen-studio/autogenstudio/web/managers/connection.py @@ -37,7 +37,8 @@ def __init__(self, db_manager: DatabaseManager): def _get_stop_message(self, reason: str) -> dict: return TeamResult( - task_result=TaskResult(messages=[TextMessage(source="user", content=reason)], stop_reason=reason), + task_result=TaskResult(messages=[TextMessage( + source="user", content=reason)], stop_reason=reason), usage="", duration=0, ).model_dump() @@ -56,7 +57,8 @@ async def connect(self, websocket: WebSocket, run_id: UUID) -> bool: self.db_manager.upsert(run) await self._send_message( - run_id, {"type": "system", "status": "connected", "timestamp": datetime.now(timezone.utc).isoformat()} + run_id, {"type": "system", "status": "connected", + "timestamp": datetime.now(timezone.utc).isoformat()} ) return True @@ -77,7 +79,8 @@ async def start_stream(self, run_id: UUID, team_manager: TeamManager, task: str, # Update run with task and status run = await self._get_run(run_id) if run: - run.task = MessageConfig(content=task, source="user").model_dump() + run.task = MessageConfig( + content=task, source="user").model_dump() run.status = RunStatus.ACTIVE self.db_manager.upsert(run) @@ -87,7 +90,8 @@ async def start_stream(self, run_id: UUID, team_manager: TeamManager, task: str, task=task, team_config=team_config, input_func=input_func, cancellation_token=cancellation_token ): if cancellation_token.is_cancelled() or run_id in self._closed_connections: - logger.info(f"Stream cancelled or connection closed for run {run_id}") + logger.info( + f"Stream cancelled or connection closed for run {run_id}") break formatted_message = self._format_message(message) @@ -105,7 +109,8 @@ async def start_stream(self, run_id: UUID, team_manager: TeamManager, task: str, if final_result: await self._update_run(run_id, RunStatus.COMPLETE, team_result=final_result) else: - logger.warning(f"No final result captured for completed run {run_id}") + logger.warning( + f"No final result captured for completed run {run_id}") await self._update_run_status(run_id, RunStatus.COMPLETE) else: await self._send_message( @@ -185,7 +190,8 @@ async def handle_input_response(self, run_id: UUID, response: str) -> None: if run_id in self._input_responses: await self._input_responses[run_id].put(response) else: - logger.warning(f"Received input response for inactive run {run_id}") + logger.warning( + f"Received input response for inactive run {run_id}") async def stop_run(self, run_id: UUID, reason: str) -> None: if run_id in self._cancellation_tokens: @@ -240,7 +246,8 @@ async def _send_message(self, run_id: UUID, message: dict) -> None: message: Message dictionary to send """ if run_id in self._closed_connections: - logger.warning(f"Attempted to send message to closed connection for run {run_id}") + logger.warning( + f"Attempted to send message to closed connection for run {run_id}") return try: @@ -248,10 +255,12 @@ async def _send_message(self, run_id: UUID, message: dict) -> None: websocket = self._connections[run_id] await websocket.send_json(message) except WebSocketDisconnect: - logger.warning(f"WebSocket disconnected while sending message for run {run_id}") + logger.warning( + f"WebSocket disconnected while sending message for run {run_id}") await self.disconnect(run_id) except Exception as e: - logger.error(f"Error sending message for run {run_id}: {e}, {message}") + logger.error( + f"Error sending message for run {run_id}: {e}, {message}") # Don't try to send error message here to avoid potential recursive loop await self._update_run_status(run_id, RunStatus.ERROR, str(e)) await self.disconnect(run_id) @@ -311,7 +320,8 @@ async def _get_run(self, run_id: UUID) -> Optional[Run]: Returns: Optional[Run]: Run object if found, None otherwise """ - response = self.db_manager.get(Run, filters={"id": run_id}, return_json=False) + response = self.db_manager.get( + Run, filters={"id": run_id}, return_json=False) return response.data[0] if response.status and response.data else None async def _update_run_status(self, run_id: UUID, status: RunStatus, error: Optional[str] = None) -> None: @@ -330,7 +340,8 @@ async def _update_run_status(self, run_id: UUID, status: RunStatus, error: Optio async def cleanup(self) -> None: """Clean up all active connections and resources when server is shutting down""" - logger.info(f"Cleaning up {len(self.active_connections)} active connections") + logger.info( + f"Cleaning up {len(self.active_connections)} active connections") try: # First cancel all running tasks @@ -341,7 +352,8 @@ async def cleanup(self) -> None: if run and run.status == RunStatus.ACTIVE: interrupted_result = TeamResult( task_result=TaskResult( - messages=[TextMessage(source="system", content="Run interrupted by server shutdown")], + messages=[TextMessage( + source="system", content="Run interrupted by server shutdown")], stop_reason="server_shutdown", ), usage="", diff --git a/python/packages/autogen-studio/pyproject.toml b/python/packages/autogen-studio/pyproject.toml index 40ec2d3c372c..3a067d5dfc46 100644 --- a/python/packages/autogen-studio/pyproject.toml +++ b/python/packages/autogen-studio/pyproject.toml @@ -33,9 +33,9 @@ dependencies = [ "alembic", "loguru", "pyyaml", - "autogen-core==0.4.0.dev6", - "autogen-agentchat==0.4.0.dev6", - "autogen-ext==0.4.0.dev6" + "autogen-core==0.4.0.dev7", + "autogen-agentchat==0.4.0.dev7", + "autogen-ext==0.4.0.dev7" ] optional-dependencies = {web = ["fastapi", "uvicorn"], database = ["psycopg"]} diff --git a/python/pyproject.toml b/python/pyproject.toml index 83535c6f5402..3b099db535e8 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -3,8 +3,8 @@ members = ["packages/*"] [tool.uv] dev-dependencies = [ - "pyright==1.1.378", - "mypy==1.10.0", + "pyright==1.1.389", + "mypy==1.13.0", "ruff==0.4.8", "pytest", "pytest-asyncio", diff --git a/python/uv.lock b/python/uv.lock index db9fd753a92a..4602559d0e01 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -22,12 +22,12 @@ members = [ requirements = [ { name = "cookiecutter" }, { name = "grpcio-tools", specifier = "~=1.62.0" }, - { name = "mypy", specifier = "==1.10.0" }, + { name = "mypy", specifier = "==1.13.0" }, { name = "mypy-protobuf" }, { name = "packaging" }, { name = "poethepoet" }, { name = "polars" }, - { name = "pyright", specifier = "==1.1.378" }, + { name = "pyright", specifier = "==1.1.389" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-mock" }, @@ -315,7 +315,7 @@ wheels = [ [[package]] name = "autogen-agentchat" -version = "0.4.0.dev6" +version = "0.4.0.dev7" source = { editable = "packages/autogen-agentchat" } dependencies = [ { name = "autogen-core" }, @@ -329,12 +329,11 @@ dev = [] [[package]] name = "autogen-core" -version = "0.4.0.dev6" +version = "0.4.0.dev7" source = { editable = "packages/autogen-core" } dependencies = [ { name = "aiohttp" }, { name = "asyncio-atexit" }, - { name = "grpcio" }, { name = "jsonref" }, { name = "openai" }, { name = "opentelemetry-api" }, @@ -345,6 +344,11 @@ dependencies = [ { name = "typing-extensions" }, ] +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, +] + [package.dev-dependencies] dev = [ { name = "aiofiles" }, @@ -367,6 +371,7 @@ dev = [ { name = "pip" }, { name = "polars" }, { name = "pydata-sphinx-theme" }, + { name = "pygments" }, { name = "python-dotenv" }, { name = "requests" }, { name = "sphinx" }, @@ -390,7 +395,7 @@ dev = [ requires-dist = [ { name = "aiohttp" }, { name = "asyncio-atexit" }, - { name = "grpcio", specifier = "~=1.62.0" }, + { name = "grpcio", marker = "extra == 'grpc'", specifier = "~=1.62.0" }, { name = "jsonref", specifier = "~=1.1.0" }, { name = "openai", specifier = ">=1.3" }, { name = "opentelemetry-api", specifier = "~=1.27.0" }, @@ -423,6 +428,7 @@ dev = [ { name = "pip" }, { name = "polars" }, { name = "pydata-sphinx-theme", specifier = "==0.15.4" }, + { name = "pygments" }, { name = "python-dotenv" }, { name = "requests" }, { name = "sphinx" }, @@ -444,7 +450,7 @@ dev = [ [[package]] name = "autogen-ext" -version = "0.4.0.dev6" +version = "0.4.0.dev7" source = { editable = "packages/autogen-ext" } dependencies = [ { name = "autogen-core" }, @@ -2407,31 +2413,31 @@ wheels = [ [[package]] name = "mypy" -version = "1.10.0" +version = "1.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/b6/297734bb9f20ddf5e831cf4a83f422ddef5a29a33463999f0959d9cdc2df/mypy-1.10.0.tar.gz", hash = "sha256:3d087fcbec056c4ee34974da493a826ce316947485cef3901f511848e687c131", size = 3022145 } +sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/82/2081dbfbbf1071e1370e57f9e327adeda060113688ec0d6bf7bbf4d7a5ad/mypy-1.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da1cbf08fb3b851ab3b9523a884c232774008267b1f83371ace57f412fe308c2", size = 10819193 }, - { url = "https://files.pythonhosted.org/packages/e8/1b/b7c9caa89955a7d9c89eac79f31550f48f2c8233b5e12fe48ef55cd2e953/mypy-1.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:12b6bfc1b1a66095ab413160a6e520e1dc076a28f3e22f7fb25ba3b000b4ef99", size = 9970689 }, - { url = "https://files.pythonhosted.org/packages/15/ae/03d3f767f1ca5576970720ea551b43b79254d12998484d8f3e63fc07561e/mypy-1.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e36fb078cce9904c7989b9693e41cb9711e0600139ce3970c6ef814b6ebc2b2", size = 12728098 }, - { url = "https://files.pythonhosted.org/packages/96/ba/8f5db8bd94c18d86033d09bbe634d471c1e9d7014cc621585973183ad1d0/mypy-1.10.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2b0695d605ddcd3eb2f736cd8b4e388288c21e7de85001e9f85df9187f2b50f9", size = 12798838 }, - { url = "https://files.pythonhosted.org/packages/0e/ad/d476f1055deea6e63a91e065ba046a7ee494705574c4f9730de439172810/mypy-1.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:cd777b780312ddb135bceb9bc8722a73ec95e042f911cc279e2ec3c667076051", size = 9365995 }, - { url = "https://files.pythonhosted.org/packages/86/ec/64ffed9ea554845ff846bd1f6fc7b07ab507be1d2e1b0d58790d7ac2ca4c/mypy-1.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3be66771aa5c97602f382230165b856c231d1277c511c9a8dd058be4784472e1", size = 10739848 }, - { url = "https://files.pythonhosted.org/packages/03/ac/f4fcb9d7a349953be5f4e78157a48b5110343a0e5228f77b3f7d1a1b8479/mypy-1.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8b2cbaca148d0754a54d44121b5825ae71868c7592a53b7292eeb0f3fdae95ee", size = 9902362 }, - { url = "https://files.pythonhosted.org/packages/7e/36/ca2b82d89828f484f1a068d9e25c08840c4cc6f6549e7ea755f4391e351f/mypy-1.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ec404a7cbe9fc0e92cb0e67f55ce0c025014e26d33e54d9e506a0f2d07fe5de", size = 12603712 }, - { url = "https://files.pythonhosted.org/packages/b5/7a/54edb45a41de3bc66e5c3d2b7512a392b3f0f8b9c3d9465b9a2456b6a115/mypy-1.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e22e1527dc3d4aa94311d246b59e47f6455b8729f4968765ac1eacf9a4760bc7", size = 12676904 }, - { url = "https://files.pythonhosted.org/packages/39/a5/e5aad5567ace09fcb179fbc3047cc2a6173743d84447b1ff71413e1a9881/mypy-1.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:a87dbfa85971e8d59c9cc1fcf534efe664d8949e4c0b6b44e8ca548e746a8d53", size = 9355997 }, - { url = "https://files.pythonhosted.org/packages/30/30/6da95275426cfd21fc0c2e96d85a45d35fc4f7d37bd3286fa49f8f465447/mypy-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a781f6ad4bab20eef8b65174a57e5203f4be627b46291f4589879bf4e257b97b", size = 10867123 }, - { url = "https://files.pythonhosted.org/packages/8c/d3/61cf1fae3b79d264f9f27de97e6e8fab8a37c85fdada5a46b6de333319f8/mypy-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b808e12113505b97d9023b0b5e0c0705a90571c6feefc6f215c1df9381256e30", size = 9859921 }, - { url = "https://files.pythonhosted.org/packages/08/5d/a46e5222bd69a873a896ab4f0b5948979e03dce46c7712ccaa5204ca8d02/mypy-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f55583b12156c399dce2df7d16f8a5095291354f1e839c252ec6c0611e86e2e", size = 12647776 }, - { url = "https://files.pythonhosted.org/packages/1d/6a/d8df60f2e48291f1a790ded56fd96421ac6a992f33c2571c0bdf0552d83a/mypy-1.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cf18f9d0efa1b16478c4c129eabec36148032575391095f73cae2e722fcf9d5", size = 12726191 }, - { url = "https://files.pythonhosted.org/packages/5a/93/9a015720bcf484d4202ea7fc5960c328c82d5eb1578950d586339ec15084/mypy-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:bc6ac273b23c6b82da3bb25f4136c4fd42665f17f2cd850771cb600bdd2ebeda", size = 9450377 }, - { url = "https://files.pythonhosted.org/packages/e9/39/0148f7ee1b7f3a86d378a23b88cb85c432f83914ceb60364efa1769c598f/mypy-1.10.0-py3-none-any.whl", hash = "sha256:f8c083976eb530019175aabadb60921e73b4f45736760826aa1689dda8208aee", size = 2580084 }, + { url = "https://files.pythonhosted.org/packages/5e/8c/206de95a27722b5b5a8c85ba3100467bd86299d92a4f71c6b9aa448bfa2f/mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a", size = 11020731 }, + { url = "https://files.pythonhosted.org/packages/ab/bb/b31695a29eea76b1569fd28b4ab141a1adc9842edde080d1e8e1776862c7/mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80", size = 10184276 }, + { url = "https://files.pythonhosted.org/packages/a5/2d/4a23849729bb27934a0e079c9c1aad912167d875c7b070382a408d459651/mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7", size = 12587706 }, + { url = "https://files.pythonhosted.org/packages/5c/c3/d318e38ada50255e22e23353a469c791379825240e71b0ad03e76ca07ae6/mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f", size = 13105586 }, + { url = "https://files.pythonhosted.org/packages/4a/25/3918bc64952370c3dbdbd8c82c363804678127815febd2925b7273d9482c/mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372", size = 9632318 }, + { url = "https://files.pythonhosted.org/packages/d0/19/de0822609e5b93d02579075248c7aa6ceaddcea92f00bf4ea8e4c22e3598/mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", size = 10939027 }, + { url = "https://files.pythonhosted.org/packages/c8/71/6950fcc6ca84179137e4cbf7cf41e6b68b4a339a1f5d3e954f8c34e02d66/mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", size = 10108699 }, + { url = "https://files.pythonhosted.org/packages/26/50/29d3e7dd166e74dc13d46050b23f7d6d7533acf48f5217663a3719db024e/mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", size = 12506263 }, + { url = "https://files.pythonhosted.org/packages/3f/1d/676e76f07f7d5ddcd4227af3938a9c9640f293b7d8a44dd4ff41d4db25c1/mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73", size = 12984688 }, + { url = "https://files.pythonhosted.org/packages/9c/03/5a85a30ae5407b1d28fab51bd3e2103e52ad0918d1e68f02a7778669a307/mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca", size = 9626811 }, + { url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900 }, + { url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818 }, + { url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275 }, + { url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783 }, + { url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197 }, + { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 }, ] [[package]] @@ -3310,14 +3316,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.378" +version = "1.1.389" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/f0/e8aa5555410d88f898bef04da2102b0a9bf144658c98d34872e91621ced2/pyright-1.1.378.tar.gz", hash = "sha256:78a043be2876d12d0af101d667e92c7734f3ebb9db71dccc2c220e7e7eb89ca2", size = 17486 } +sdist = { url = "https://files.pythonhosted.org/packages/72/4e/9a5ab8745e7606b88c2c7ca223449ac9d82a71fd5e31df47b453f2cb39a1/pyright-1.1.389.tar.gz", hash = "sha256:716bf8cc174ab8b4dcf6828c3298cac05c5ed775dda9910106a5dcfe4c7fe220", size = 21940 } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/c6/f0d4bc20c13b20cecfbf13c699477c825e45767f1dc5068137323f86e495/pyright-1.1.378-py3-none-any.whl", hash = "sha256:8853776138b01bc284da07ac481235be7cc89d3176b073d2dba73636cb95be79", size = 18222 }, + { url = "https://files.pythonhosted.org/packages/1b/26/c288cabf8cfc5a27e1aa9e5029b7682c0f920b8074f45d22bf844314d66a/pyright-1.1.389-py3-none-any.whl", hash = "sha256:41e9620bba9254406dc1f621a88ceab5a88af4c826feb4f614d95691ed243a60", size = 18581 }, ] [[package]]