diff --git a/.github/workflows/agents-playback.yml b/.github/workflows/agents-playback.yml new file mode 100644 index 0000000..d58fb0f --- /dev/null +++ b/.github/workflows/agents-playback.yml @@ -0,0 +1,68 @@ +name: Agents playback + +on: + pull_request: + push: + branches: [main, develop] + workflow_dispatch: + +jobs: + playback: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + env: + CONDUCTOR_SERVER_URL: http://localhost:18080/api + CONDUCTOR_AGENT_LLM_MODEL: mock/mockLLM + CONDUCTOR_AGENTS_PLAYBACK: 'true' + CONDUCTOR_PLAYBACK_WORK_DIR: tmp/agent-playback + GITHUB_REPOS_URL: http://localhost:3002/users/Conductor/repos?per_page=5&sort=updated + steps: + - uses: actions/checkout@v4 + - name: Checkout matching server and shared recordings + uses: actions/checkout@v4 + with: + repository: conductor-oss/conductor + ref: main + path: tmp/conductor + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - uses: gradle/actions/setup-gradle@v4 + - name: Build playback server + working-directory: tmp/conductor + run: ./gradlew --no-daemon :conductor-server:bootJar -x test + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + - name: Start HTTP and MCP services + uses: ./tmp/conductor/.github/actions/start-playback-services + - name: Start fresh playback server + id: server + uses: ./tmp/conductor/.github/actions/start-playback + - name: Run all agent examples + env: + CONDUCTOR_PLAYBACK_SERVICES_STARTED: 'true' + run: bash scripts/run-agents-playback.sh tmp/conductor + - name: Check playback outcomes + if: always() && steps.server.outcome == 'success' + uses: ./tmp/conductor/.github/actions/check-playback + with: + server-url: http://localhost:18080/api + - name: Upload playback diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: agents-playback-logs + path: | + tmp/agent-playback/*.log + tmp/agent-playback/results.json + - name: Stop test services + if: always() + run: | + for file in tmp/agent-playback/*.pid; do + [ -f "$file" ] && kill "$(cat "$file")" 2>/dev/null || true + done diff --git a/.rspec_agents_status b/.rspec_agents_status new file mode 100644 index 0000000..c5da245 --- /dev/null +++ b/.rspec_agents_status @@ -0,0 +1,3 @@ +example_id | status | run_time | +------------------------------------------------- | ------ | ------------ | +./spec/agents/replay/tool_happy_path_spec.rb[1:1] | passed | 3.11 seconds | diff --git a/.rubocop.yml b/.rubocop.yml index a384d8f..b6f3904 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -199,3 +199,10 @@ RSpec/FilePath: RSpec/VerifiedDoubles: Enabled: false + +# The agents Tools DSL has its own `describe :tool_name, 'text'`; spec support files use it +RSpec/DescribeSymbol: + Exclude: + - 'spec/support/**/*' + - 'spec/agents/support/**/*' + - 'examples/**/*' diff --git a/CHANGELOG.md b/CHANGELOG.md index 71828dc..4595cac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,11 +36,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Task classes: `SimpleTask`, `SwitchTask`, `ForkTask`, `JoinTask`, `DoWhileTask`, `HttpTask`, `SubWorkflowTask`, `WaitTask`, `TerminateTask`, `SetVariableTask`, `DynamicForkTask`, `JavascriptTask`, `JsonJqTask`, `EventTask`, `HttpPollTask`, `DynamicTask`, `HumanTask`, `StartWorkflowTask`, `KafkaPublishTask`, `WaitForWebhookTask` - LLM task classes: `LlmChatCompleteTask`, `LlmTextCompleteTask`, `LlmGenerateEmbeddingsTask`, `LlmIndexTextTask`, `LlmIndexDocumentTask`, `LlmSearchIndexTask`, `LlmQueryEmbeddingsTask`, `LlmStoreEmbeddingsTask`, `LlmSearchEmbeddingsTask`, `GenerateImageTask`, `GenerateAudioTask`, `GetDocumentTask`, `ListMcpToolsTask`, `CallMcpToolTask` -### Fixed - -- `SchedulerResourceApi#pause_schedule` / `#resume_schedule` now work against both Conductor server families. The client sends `PUT` first and falls back to `GET` on a `405` -- and only on a `405`. OSS Conductor maps these two per-schedule routes `@PutMapping`-only, so the previous `GET`-only calls failed there outright; Orkes Conductor accepts both verbs as of the dual `@RequestMapping(method = {GET, PUT})` added in 2026-07, and is `GET`-only in deployments older than that. `pause_all_schedules` / `resume_all_schedules` remain `GET`, which is how both families map those admin endpoints. Matches the python-sdk, go-sdk, javascript-sdk, csharp-sdk and rust-sdk clients; `spec/conductor/http/api/scheduler_resource_api_spec.rb` pins the whole contract -- `Conductor::AuthenticationSettings` is no longer referenced as `Conductor::Configuration::AuthenticationSettings`, which raised `NameError: uninitialized constant`. The class has always been defined directly under `Conductor`. Fixed in `RactorTaskRunner`'s in-Ractor configuration rebuild (where it was a live failure) and in the `Conductor` / `OrkesClients` doc comments (where it told users to write the broken form) - ### Migration Guide **Before (old DSL):** diff --git a/Gemfile.lock b/Gemfile.lock index 97fbb26..29cb5b7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -47,10 +47,16 @@ GEM fiber-local (1.1.0) fiber-storage fiber-storage (1.0.1) + hana (1.3.7) hashdiff (1.2.1) io-console (0.8.2) io-event (1.16.0) json (2.7.6) + json_schemer (2.5.0) + bigdecimal + hana (~> 1.3) + regexp_parser (~> 2.0) + simpleidn (~> 0.2) method_source (1.1.0) metrics (0.15.0) net-http-persistent (4.0.8) @@ -105,9 +111,9 @@ GEM rubocop-capybara (~> 2.17) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) + simpleidn (0.3.0) traces (0.18.2) unicode-display_width (2.6.0) - vcr (6.1.0) webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) @@ -120,13 +126,13 @@ PLATFORMS DEPENDENCIES async (~> 2.0) conductor_ruby! + json_schemer (~> 2.0) prometheus-client (~> 4.0) pry (~> 0.14) rake (~> 13.0) rspec (~> 3.0) rubocop (~> 1.0) rubocop-rspec (~> 2.0) - vcr (~> 6.0) webmock (~> 3.0) webrick (~> 1.8) diff --git a/conductor_ruby.gemspec b/conductor_ruby.gemspec index 632163a..e828d15 100644 --- a/conductor_ruby.gemspec +++ b/conductor_ruby.gemspec @@ -41,11 +41,11 @@ Gem::Specification.new do |spec| spec.add_dependency 'json', '>= 2.0' # Development dependencies (alphabetically sorted) + spec.add_development_dependency 'json_schemer', '~> 2.0' spec.add_development_dependency 'pry', '~> 0.14' spec.add_development_dependency 'rake', '~> 13.0' spec.add_development_dependency 'rspec', '~> 3.0' spec.add_development_dependency 'rubocop', '~> 1.0' spec.add_development_dependency 'rubocop-rspec', '~> 2.0' - spec.add_development_dependency 'vcr', '~> 6.0' spec.add_development_dependency 'webmock', '~> 3.0' end diff --git a/docs/agents/README.md b/docs/agents/README.md new file mode 100644 index 0000000..f475a51 --- /dev/null +++ b/docs/agents/README.md @@ -0,0 +1,17 @@ +# Agents + +Durable AI agents on Conductor, written in Ruby. Tools run as Conductor worker +tasks, approvals and schedules wait server-side, and an execution survives a +process restart. + +Requires Ruby 3.0+ and a Conductor server with an LLM provider configured. + +- [Getting started](getting-started.md) +- Concepts: [agents](concepts/agents.md), [tools](concepts/tools.md), [multi-agent](concepts/multi-agent.md), + [guardrails](concepts/guardrails.md), [termination](concepts/termination.md), [callbacks](concepts/callbacks.md), + [stateful](concepts/stateful.md), [streaming and approval](concepts/streaming-hitl.md), + [structured output](concepts/structured-output.md), [runtime modes](concepts/deploy-serve-run.md), + [scheduling](concepts/scheduling.md) +- Reference: [API map](reference/api.md), [runtime](reference/runtime.md), [client](reference/client.md), + [agent fields](reference/agent-definition.md), [wire contract](reference/agent-schema.md) +- [Examples](../../examples/agents/) diff --git a/docs/agents/concepts/agents.md b/docs/agents/concepts/agents.md new file mode 100644 index 0000000..243c5a3 --- /dev/null +++ b/docs/agents/concepts/agents.md @@ -0,0 +1,26 @@ +# Agents + +```ruby +require 'conductor/agents' +include Conductor::Agents + +tool def get_weather(city: String) + "Weather for #{city}" +end + +agent = Agent.new(name: 'weather', model: 'openai/gpt-4o-mini', + instructions: 'Answer concisely.', tools: [:get_weather]) +puts agent.call_sync('Weather in Seattle?') +``` + +- `name` must match `^[a-zA-Z_][a-zA-Z0-9_-]*$`. +- `model` is `provider/model`; the provider is an integration on the server. + Omit it only for sub-agents that inherit the parent's model. +- `instructions` is a String, a Proc evaluated at compile time, or a `PromptTemplate`. +- Per-call options: `session_id:`, `media:`, `context:`, `idempotency_key:`, + `timeout_seconds:`. Don't mutate a shared agent per request. + +`call_sync` compiles the agent, starts workers for its tools, and returns the +answer. A tool task stuck in `SCHEDULED` means no process is running its worker. + +See [tools](tools.md), [multi-agent](multi-agent.md), [runtime modes](deploy-serve-run.md). diff --git a/docs/agents/concepts/callbacks.md b/docs/agents/concepts/callbacks.md new file mode 100644 index 0000000..a519ba4 --- /dev/null +++ b/docs/agents/concepts/callbacks.md @@ -0,0 +1,18 @@ +# Callbacks + +```ruby +agent.callback(:before_tool) { |**event| logger.info(event[:tool_name]) } + +class Audit < CallbackHandler + def on_agent_end(**event) = logger.info(event) +end +agent.add_callback(Audit.new) +``` + +Positions: `before_agent`, `after_agent`, `before_model`, `after_model`, +`before_tool`, `after_tool`. Handler methods are `on_agent_start`, `on_agent_end`, +`on_model_start`, `on_model_end`, `on_tool_start`, `on_tool_end`. + +Callbacks run in your process and don't change the workflow. Keep them fast, and +don't make them the only record of anything: a restart drops them. Durable side +effects belong in a tool. diff --git a/docs/agents/concepts/deploy-serve-run.md b/docs/agents/concepts/deploy-serve-run.md new file mode 100644 index 0000000..50d6f3b --- /dev/null +++ b/docs/agents/concepts/deploy-serve-run.md @@ -0,0 +1,20 @@ +# Runtime modes + +| Call | Does | +|---|---| +| `runtime.compile(agent)` | Compile; returns `workflowDef` and `requiredWorkers`. | +| `runtime.deploy(agent)` | Compile and register on the server. | +| `runtime.serve(agent)` | Deploy, run tool workers, block until INT/TERM. | +| `runtime.call_sync(agent, prompt)` | Start, run workers, return the answer. | +| `runtime.call_async(agent, prompt)` | Same, but return an `Execution` immediately. | +| `Execution.find(id)` | Reattach to an existing execution. | + +`runtime` is `Conductor::Agents.runtime` (built from ENV) or your own +`AgentRuntime.new(configuration: ...)`. + +Local development: `call_sync`. CI/CD: `compile` to inspect, `deploy` to +release. Production: `serve` in a long-lived worker process, then start runs +from anywhere with the [client](../reference/client.md). + +Tools must be defined in the process that calls `serve` or `call_*`. Call +`shutdown` before a short-lived process exits. diff --git a/docs/agents/concepts/guardrails.md b/docs/agents/concepts/guardrails.md new file mode 100644 index 0000000..e30b0dd --- /dev/null +++ b/docs/agents/concepts/guardrails.md @@ -0,0 +1,24 @@ +# Guardrails + +Guardrails check input or output before the next step. + +```ruby +no_pii = Guardrail.new(name: 'no_pii', position: :output, on_fail: :retry) do |content| + GuardrailResult.new(passed: !content.match?(/\d{3}-\d{2}-\d{4}/), message: 'SSN found') +end + +agent = Agent.new(name: 'assistant', model: 'openai/gpt-4o-mini', guardrails: [ + RegexGuardrail.new([/\S+@\S+/], mode: :block, position: :output), + LlmGuardrail.new('openai/gpt-4o-mini', 'No medical advice.'), + no_pii +]) +agent.redact(%w[password token]) # shortcut for a blocking regex guardrail +``` + +`on_fail:` is `:raise`, `:retry` (up to `max_retries:`), `:fix`, or `:human`. +Retry only when a new model response could plausibly pass. + +Use `RegexGuardrail` for format checks, `LlmGuardrail` for policy, and a block +only when the rule needs application state. Put a guardrail on a `Tool` +(`guardrails:`) when it protects one side effect. Don't send secrets to an LLM +guardrail. Decisions show up in the execution history. diff --git a/docs/agents/concepts/multi-agent.md b/docs/agents/concepts/multi-agent.md new file mode 100644 index 0000000..4164769 --- /dev/null +++ b/docs/agents/concepts/multi-agent.md @@ -0,0 +1,21 @@ +# Multi-agent + +```ruby +team = Agent.new(name: 'support', model: 'openai/gpt-4o-mini', + agents: [billing, shipping], strategy: :handoff) +pipeline = researcher >> writer >> editor # sequential +``` + +Strategies: `:sequential`, `:parallel`, `:handoff` (default; the model picks a +specialist), `:router` (a `router:` agent picks), `:swarm`, `:round_robin`, +`:random`, `:manual`, `:plan_execute`. + +- `a.hands_off_to(b, on: 'billing')` hands off when the text appears; `on:` also + takes a callable. +- `Tool.agent(child)` calls a child and returns its answer to the parent + instead of transferring control. +- `plan_execute(name:, tools:, model:, planner_instructions:, fallback_instructions:, fallback_max_turns:)` + builds a planner that writes a plan executed as a durable sub-workflow. + +Every open-ended design needs `max_turns:` and a [termination](termination.md) +condition. Each child appears in the execution history. diff --git a/docs/agents/concepts/scheduling.md b/docs/agents/concepts/scheduling.md new file mode 100644 index 0000000..948dcf0 --- /dev/null +++ b/docs/agents/concepts/scheduling.md @@ -0,0 +1,8 @@ +# Scheduling + +Deploy the agent, then create a workflow schedule for it with +`OrkesClients#get_scheduler_client`. Agents compile to workflows, so the normal +scheduler applies; there is no agent-specific schedule API. + +Use a stable schedule name, a timezone-aware cron, and idempotent input. Pause or +delete the schedule before deleting the agent or stopping its workers. diff --git a/docs/agents/concepts/stateful.md b/docs/agents/concepts/stateful.md new file mode 100644 index 0000000..7390a11 --- /dev/null +++ b/docs/agents/concepts/stateful.md @@ -0,0 +1,14 @@ +# Stateful agents + +```ruby +agent = Agent.new(name: 'chat', model: 'openai/gpt-4o-mini', stateful: true, + memory: ConversationMemory.new(max_messages: 50)) +agent.call_sync('Hi, I am Ana.', session_id: 'user-42') +agent.call_sync('What is my name?', session_id: 'user-42') +``` + +`stateful: true` keeps conversation state on the server per `session_id:` and +pins the run's tool workers to its own task domain. After a process restart, +`Execution.find(execution_id)` reattaches. + +Bound context with `max_messages:` rather than growing the prompt. diff --git a/docs/agents/concepts/streaming-hitl.md b/docs/agents/concepts/streaming-hitl.md new file mode 100644 index 0000000..0ad4bcb --- /dev/null +++ b/docs/agents/concepts/streaming-hitl.md @@ -0,0 +1,27 @@ +# Streaming and approval + +```ruby +execution = agent.call_async('Transfer $500', on_event: ->(e) { puts e['event'] }) +execution.result(timeout: 120) +``` + +Events (`message`, `tool_call`, `tool_result`, `waiting`, `done`, `error`) +arrive over SSE; the runtime falls back to polling when SSE is unavailable. +`execution.partial_text` holds the text so far; nothing is final until +`execution.done?`. + +Tools with `approval_required: true` pause the execution: + +```ruby +agent.on_approval do |request| + puts request.tool_name, request.arguments + request.approve # or request.reject('no'), request.respond(hash) +end +``` + +`Tool.human` asks a person a question; `Tool.wait_for_message` waits for +`execution.signal(message)`. Without a handler, use `execution.approve`, +`reject`, `signal`, or the [client](../reference/client.md). Approval calls are +safe to repeat. + +Call `Conductor::Agents.shutdown` when a short-lived program is done. diff --git a/docs/agents/concepts/structured-output.md b/docs/agents/concepts/structured-output.md new file mode 100644 index 0000000..86c4260 --- /dev/null +++ b/docs/agents/concepts/structured-output.md @@ -0,0 +1,17 @@ +# Structured output + +```ruby +agent = Agent.new(name: 'extract', model: 'openai/gpt-4o-mini', + instructions: 'Extract the invoice.', + output_type: { 'type' => 'object', + 'properties' => { 'total' => { 'type' => 'number' } }, + 'required' => ['total'] }) +execution = agent.call_async(text) +execution.result +execution.output # => { 'total' => 42.0 } +``` + +`output_type:` is a JSON Schema hash, or any object with `to_json_schema`. The +server validates the final answer; on failure the run errors rather than +returning malformed data. Keep the schema small and ask for the same shape in +the instructions. diff --git a/docs/agents/concepts/termination.md b/docs/agents/concepts/termination.md new file mode 100644 index 0000000..de9ef85 --- /dev/null +++ b/docs/agents/concepts/termination.md @@ -0,0 +1,14 @@ +# Termination + +```ruby +agent.stop_when('DONE') +agent.stop_after(messages: 20) +agent.termination = Termination::TokenUsage.new(max_total_tokens: 50_000) | + Termination::TextMention.new('DONE') +``` + +Conditions: `Termination::MaxMessage`, `StopMessage`, `TextMention`, +`TokenUsage`. Combine with `&` and `|`. Always set `max_turns:` too. + +Stop a running execution with `execution.stop` or `AgentClient#stop`. Stopping +does not undo a tool call that already ran. diff --git a/docs/agents/concepts/tools.md b/docs/agents/concepts/tools.md new file mode 100644 index 0000000..087c6e8 --- /dev/null +++ b/docs/agents/concepts/tools.md @@ -0,0 +1,42 @@ +# Tools + +`tool def` turns a method into a tool. Keyword defaults give the schema +(`city: String`, `units: 'metric'`). Each call is a retryable Conductor task. + +```ruby +require 'conductor/agents' +include Conductor::Agents + +tool def create_issue(title: String) + Github.create_issue(title, token: secret('GITHUB_TOKEN')) + "created: #{title}" +end +describe :create_issue, 'Create a GitHub issue.' +requires_approval :create_issue +``` + +`secret('NAME')` inside the body both declares the credential and reads it at +run time. The server resolves it from its secret store into the task; nothing goes +through ENV or workflow input. Use `credentials:` for names built dynamically. + +Server-side tools need no worker: + +| Tool | Factory | +|---|---| +| HTTP endpoint | `Tool.http(name, url, ...)` | +| OpenAPI / Postman | `Tool.api(url)` | +| MCP server | `Tool.mcp(url)` | +| Human answer | `Tool.human(name, description:)` | +| Wait for a message | `Tool.wait_for_message(name, description:)` | +| Media, PDF, vectors | `Tool.image`, `.audio`, `.video`, `.pdf`, `.index`, `.search` | +| Another agent | `Tool.agent(child)` or `agent.add_tool(child)` | + +Options on `tool`: `name:` (when the tool name differs from the method name), +`description:`, `guardrails:`, `credentials:`, `external:`, `stateful:`, +`retry_count:`, `retry_delay_seconds:`, `timeout_seconds:`, `max_calls:`, +`approval_required:`, `input_schema:`, `output_schema:`. The factories take the +subset that applies to them. `tool.with_guardrails(g)` returns a guarded copy of +any tool. + +A tool stuck in `SCHEDULED` has no worker polling. `CredentialNotFoundError` +means the secret is missing on the server. diff --git a/docs/agents/getting-started.md b/docs/agents/getting-started.md new file mode 100644 index 0000000..f95e6f9 --- /dev/null +++ b/docs/agents/getting-started.md @@ -0,0 +1,29 @@ +# Getting started + +```shell +gem install conductor_ruby +export CONDUCTOR_SERVER_URL=http://localhost:8080/api +export CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini +``` + +Set `CONDUCTOR_AUTH_KEY` and `CONDUCTOR_AUTH_SECRET` for authenticated servers. +The LLM provider is configured on the server, not in your code. + +```ruby +require 'conductor/agents' +include Conductor::Agents + +agent = Agent.new(name: 'greeter', model: 'openai/gpt-4o-mini', + instructions: 'You are a friendly assistant.') +puts agent.call_sync('Say hello.') +Conductor::Agents.shutdown +``` + +Or run the checked-in version: + +```shell +bundle exec ruby -Ilib examples/agents/01_basic_agent.rb +``` + +A model error means the provider is not set up on the server. A connection error +means `CONDUCTOR_SERVER_URL` is wrong. diff --git a/docs/agents/reference/agent-definition.md b/docs/agents/reference/agent-definition.md new file mode 100644 index 0000000..f91c988 --- /dev/null +++ b/docs/agents/reference/agent-definition.md @@ -0,0 +1,10 @@ +# Agent fields + +`Agent.new(name:, model:, instructions:, tools:, agents:, strategy:, router:, +output_type:, guardrails:, memory:, termination:, handoffs:, callbacks:, +credentials:, max_turns: 25, max_tokens:, timeout_seconds:, temperature:, +stateful:, metadata:, description:, planner:, fallback:, fallback_max_turns:)` + +`name` must match `^[a-zA-Z_][a-zA-Z0-9_-]*$`. A nil `model` inherits the +parent's. The serializer in `lib/conductor/agents/config_serializer.rb` is the +source of truth for what each field becomes on the wire. diff --git a/docs/agents/reference/agent-schema.md b/docs/agents/reference/agent-schema.md new file mode 100644 index 0000000..1d6b67e --- /dev/null +++ b/docs/agents/reference/agent-schema.md @@ -0,0 +1,13 @@ +# Wire contract + +`ConfigSerializer.serialize(agent)` produces the `agentConfig` hash sent to the +server. It is byte-for-byte the Python SDK's output: keys are camelCase, and +`agents`, `router`, `planner`, `fallback` nest recursively. + +The server schema is vendored at +[spec/fixtures/agents/agent-schema.json](../../../spec/fixtures/agents/agent-schema.json). +`spec/conductor/agents/contract_spec.rb` validates the golden configs against +it; `examples_spec.rb` compares every example to its Python-generated fixture. + +Adding a field means changing the serializer, the schema, and both specs +together. The Python SDK serializer is the parity source. diff --git a/docs/agents/reference/api.md b/docs/agents/reference/api.md new file mode 100644 index 0000000..f8306fb --- /dev/null +++ b/docs/agents/reference/api.md @@ -0,0 +1,15 @@ +# API map + +Everything lives under `Conductor::Agents` after `require 'conductor/agents'`. + +| Need | Ruby | Guide | +|---|---|---| +| Define an agent | `Agent` | [agents](../concepts/agents.md) | +| Tools | `tool def`, `Tool.*` | [tools](../concepts/tools.md) | +| Run | `AgentRuntime`, `Conductor::Agents.runtime` | [runtime](runtime.md) | +| Control a run | `Execution`, `ApprovalRequest`, `Client::AgentClient` | [client](client.md) | +| Safety | `Guardrail`, `RegexGuardrail`, `LlmGuardrail`, `Termination::*` | [guardrails](../concepts/guardrails.md) | +| Compose | `strategy:`, `>>`, `hands_off_to`, `plan_execute` | [multi-agent](../concepts/multi-agent.md) | +| Wire format | `ConfigSerializer` | [contract](agent-schema.md) | + +Signatures are documented in YARD comments under `lib/conductor/agents/`. diff --git a/docs/agents/reference/client.md b/docs/agents/reference/client.md new file mode 100644 index 0000000..132e17f --- /dev/null +++ b/docs/agents/reference/client.md @@ -0,0 +1,16 @@ +# AgentClient + +`Conductor::Client::AgentClient` wraps `/api/agent/*` with the SDK's normal +transport and auth. Get it with `OrkesClients#get_agent_client`. Use it when the +tools are server-side or already served elsewhere and you only need control. + +| | Methods | +|---|---| +| Lifecycle | `compile_agent`, `deploy_agent`, `start_agent` | +| Inspect | `get_status`, `get_execution`, `list_executions`, `list_agents`, `get_agent`, `delete_agent` | +| Control | `approve`, `reject`, `respond`, `send_message`, `signal`, `pause`, `resume`, `stop`, `cancel` | +| Events | `stream_sse(execution_id, last_event_id:) { |event| }` | + +`stream_sse` raises `SseUnavailableError` when the server has no stream; poll +`get_status` instead. Control calls change a durable execution, so authorize +callers and make retries idempotent. diff --git a/docs/agents/reference/runtime.md b/docs/agents/reference/runtime.md new file mode 100644 index 0000000..80c3dbb --- /dev/null +++ b/docs/agents/reference/runtime.md @@ -0,0 +1,24 @@ +# AgentRuntime + +One per process. `Conductor::Agents.runtime` is the default, built from ENV; +`AgentRuntime.new(configuration:, agent_config:, logger:)` for anything else. + +| Method | Returns | +|---|---| +| `call_sync(agent, prompt, session_id:, timeout:, **opts)` | answer `String` | +| `call_async(agent, prompt, on_event:, **opts, &on_done)` | `Execution` | +| `compile(agent)` | `{ 'workflowDef', 'requiredWorkers' }` | +| `deploy(*agents)` | deployed names | +| `serve(*agents, blocking: true)` | blocks | +| `shutdown(timeout: 5)` | stops workers and streams | + +`opts`: `media:`, `context:`, `idempotency_key:`, `timeout_seconds:`. + +`AgentConfig.from_env` reads `CONDUCTOR_AGENT_WORKER_POLL_INTERVAL`, +`CONDUCTOR_AGENT_WORKER_THREADS`, `CONDUCTOR_AGENT_INTEGRATIONS_AUTO_REGISTER`, +`CONDUCTOR_AGENT_STREAMING_ENABLED`. Server URL and auth come from +`Configuration` (`CONDUCTOR_*`). + +`Execution`: `result(timeout:)`, `output`, `status`, `done?`, `waiting?`, +`partial_text`, `tool_calls`, `token_usage`, `approve`, `reject`, `signal`, +`pause`, `resume`, `stop`, `cancel`. `Execution.find(id)` reattaches. diff --git a/examples/agents/01_basic_agent.rb b/examples/agents/01_basic_agent.rb new file mode 100644 index 0000000..f6bd937 --- /dev/null +++ b/examples/agents/01_basic_agent.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Port of python-sdk/examples/agents/01_basic_agent.py. +# Run: bundle exec ruby -Ilib examples/agents/01_basic_agent.rb +require 'conductor/agents' + +module Example01BasicAgent + include Conductor::Agents + extend Conductor::Agents::Tools + + def self.build(model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini')) + agent = Agent.new( + name: "greeter", + model: model, + instructions: "You are a friendly assistant. Keep responses brief." + ) + agent + end + + def self.run(runtime: Conductor::Agents.runtime, input: $stdin, output: $stdout) + agent = build + executions = [] + execution = runtime.call_async(agent, "Say hello and tell me a fun fact about Python.") + output.puts execution.result(timeout: 180) + executions << execution + executions + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Example01BasicAgent.run + ensure + Conductor::Agents.shutdown + end +end diff --git a/examples/agents/02a_simple_tools.rb b/examples/agents/02a_simple_tools.rb new file mode 100644 index 0000000..8400b35 --- /dev/null +++ b/examples/agents/02a_simple_tools.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Port of python-sdk/examples/agents/02a_simple_tools.py. +# Run: bundle exec ruby -Ilib examples/agents/02a_simple_tools.rb +require 'conductor/agents' + +module Example02aSimpleTools + include Conductor::Agents + extend Conductor::Agents::Tools + + def get_weather(city: String) + { city: city, temp_f: 72, condition: "Sunny" } + end + tool :get_weather, description: "Get the current weather for a city." + + def get_stock_price(symbol: String) + { symbol: symbol, price: 182.50, change: "+1.2%" } + end + tool :get_stock_price, description: "Get the current stock price for a ticker symbol." + + def self.build(model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini')) + agent = Agent.new( + name: "weather_stock_agent", + model: model, + tools: [self[:get_weather], self[:get_stock_price]], + instructions: "You are a helpful assistant. Use tools to answer questions." + ) + agent + end + + def self.run(runtime: Conductor::Agents.runtime, input: $stdin, output: $stdout) + agent = build + executions = [] + execution = runtime.call_async(agent, "What's the weather like in San Francisco?") + output.puts execution.result(timeout: 180) + executions << execution + executions + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Example02aSimpleTools.run + ensure + Conductor::Agents.shutdown + end +end diff --git a/examples/agents/02c_tool_retry_config.rb b/examples/agents/02c_tool_retry_config.rb new file mode 100644 index 0000000..05f8985 --- /dev/null +++ b/examples/agents/02c_tool_retry_config.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# Port of python-sdk/examples/agents/02c_tool_retry_config.py. +# Run: bundle exec ruby -Ilib examples/agents/02c_tool_retry_config.rb +require 'conductor/agents' + +module Example02cToolRetryConfig + include Conductor::Agents + extend Conductor::Agents::Tools + + def call_external_api(query: String) + { result: "Data for: #{query}", source: "external_api" } + end + tool :call_external_api, description: "Call an unreliable external API that may need aggressive retries.", retry_policy: "exponential_backoff", retry_count: 5, retry_delay_seconds: 1 + + def query_database(sql: String) + { rows: [{ id: 1, value: sql }], count: 1 } + end + tool :query_database, description: "Run a database query with fixed-interval retries for transient connection issues." , retry_policy: "fixed", retry_count: 3, retry_delay_seconds: 5 + + def process_data(data: String) + { processed: data, status: "ok" } + end + tool :process_data, description: "Process data locally — light retries with linear backoff." , retry_policy: "linear_backoff", retry_count: 2, retry_delay_seconds: 2 + + def self.build(model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini')) + agent = Agent.new( + name: "retry_config_demo", + model: model, + tools: [self[:call_external_api], self[:query_database], self[:process_data]], + instructions: "You help users fetch and process data. Use the appropriate tool for each request." + ) + agent + end + + def self.run(runtime: Conductor::Agents.runtime, input: $stdin, output: $stdout) + agent = build + executions = [] + execution = runtime.call_async(agent, "Look up the latest Python release info from the API.") + output.puts execution.result(timeout: 180) + executions << execution + executions + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Example02cToolRetryConfig.run + ensure + Conductor::Agents.shutdown + end +end diff --git a/examples/agents/04_http_and_mcp_tools.rb b/examples/agents/04_http_and_mcp_tools.rb new file mode 100644 index 0000000..5a98904 --- /dev/null +++ b/examples/agents/04_http_and_mcp_tools.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# Port of python-sdk/examples/agents/04_http_and_mcp_tools.py. +# Run: bundle exec ruby -Ilib examples/agents/04_http_and_mcp_tools.rb +require 'conductor/agents' + +module Example04HttpAndMcpTools + include Conductor::Agents + extend Conductor::Agents::Tools + + def format_report(title: String, body: String) + { report: "=== #{title} ===\n#{body}\n#{'=' * (title.length + 8)}" } + end + tool :format_report, description: "Format a title and body into a structured report." + + def self.build(model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini')) + reverse_api = Tool.http( + "reverse_string", + "http://localhost:3001/api/string/reverse", + description: "Reverse a string using the HTTP API", + method: "POST", + headers: { "Authorization" => "Bearer ${HTTP_TEST_API_KEY}" }, + credentials: ["HTTP_TEST_API_KEY"], + input_schema: { "type" => "object", "properties" => { "text" => { "type" => "string", "description" => "Text to reverse" } }, "required" => ["text"] } + ) + mcp_test_tools = Tool.mcp( + "http://localhost:3001/mcp", + name: "mcp_test_tools", + description: "Deterministic test tools via MCP — math, string, collection, encoding, hash, datetime, validation, and conversion operations.", + headers: { "Authorization" => "Bearer ${MCP_TEST_API_KEY}" }, + credentials: ["MCP_TEST_API_KEY"] + ) + agent = Agent.new( + name: "http_tools_demo", + model: model, + tools: [self[:format_report], reverse_api, mcp_test_tools], + instructions: "You can reverse strings and format reports. When asked to reverse a string, use reverse_string first, then format_report with the result." + ) + agent + end + + def self.run(runtime: Conductor::Agents.runtime, input: $stdin, output: $stdout) + agent = build + executions = [] + execution = runtime.call_async(agent, "Reverse the string 'hello world' and add 33 and 21 append the result to that string, then write a report with the result.") + output.puts execution.result(timeout: 180) + executions << execution + executions + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Example04HttpAndMcpTools.run + ensure + Conductor::Agents.shutdown + end +end diff --git a/examples/agents/05_handoffs.rb b/examples/agents/05_handoffs.rb new file mode 100644 index 0000000..31ecec1 --- /dev/null +++ b/examples/agents/05_handoffs.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# Port of python-sdk/examples/agents/05_handoffs.py. +# Run: bundle exec ruby -Ilib examples/agents/05_handoffs.rb +require 'conductor/agents' + +module Example05Handoffs + include Conductor::Agents + extend Conductor::Agents::Tools + + def check_balance(account_id: String) + { account_id: account_id, balance: 5432.10, currency: "USD" } + end + tool :check_balance, description: "Check the balance of a bank account." + + def lookup_order(order_id: String) + { order_id: order_id, status: "shipped", eta: "2 days" } + end + tool :lookup_order, description: "Look up the status of an order." + + def get_pricing(product: String) + { product: product, price: 99.99, discount: "10% off" } + end + tool :get_pricing, description: "Get pricing information for a product." + + def self.build(model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini')) + billing_agent = Agent.new( + name: "billing", + model: model, + instructions: "You handle billing questions: balances, payments, invoices.", + tools: [self[:check_balance]] + ) + technical_agent = Agent.new( + name: "technical", + model: model, + instructions: "You handle technical questions: order status, shipping, returns.", + tools: [self[:lookup_order]] + ) + sales_agent = Agent.new( + name: "sales", + model: model, + instructions: "You handle sales questions: pricing, products, promotions.", + tools: [self[:get_pricing]] + ) + support = Agent.new( + name: "support", + model: model, + instructions: "Route customer requests to the right specialist: billing, technical, or sales.", + agents: [billing_agent, technical_agent, sales_agent], + strategy: :handoff + ) + support + end + + def self.run(runtime: Conductor::Agents.runtime, input: $stdin, output: $stdout) + support = build + executions = [] + execution = runtime.call_async(support, "What's the balance on account ACC-123?") + output.puts execution.result(timeout: 180) + executions << execution + executions + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Example05Handoffs.run + ensure + Conductor::Agents.shutdown + end +end diff --git a/examples/agents/06_sequential_pipeline.rb b/examples/agents/06_sequential_pipeline.rb new file mode 100644 index 0000000..5a3d0a4 --- /dev/null +++ b/examples/agents/06_sequential_pipeline.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Port of python-sdk/examples/agents/06_sequential_pipeline.py. +# Run: bundle exec ruby -Ilib examples/agents/06_sequential_pipeline.rb +require 'conductor/agents' + +module Example06SequentialPipeline + include Conductor::Agents + extend Conductor::Agents::Tools + + def self.build(model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini')) + researcher = Agent.new( + name: "researcher", + model: model, + instructions: "You are a researcher. Given a topic, provide key facts and data points. Be thorough but concise. Output raw research findings." + ) + writer = Agent.new( + name: "writer", + model: model, + instructions: "You are a writer. Take research findings and write a clear, engaging article. Use headers and bullet points where appropriate." + ) + editor = Agent.new( + name: "editor", + model: model, + instructions: "You are an editor. Review the article for clarity, grammar, and tone. Make improvements and output the final polished version." + ) + pipeline = researcher >> writer >> editor + pipeline + end + + def self.run(runtime: Conductor::Agents.runtime, input: $stdin, output: $stdout) + pipeline = build + executions = [] + execution = runtime.call_async(pipeline, "The impact of AI agents on software development in 2025") + output.puts execution.result(timeout: 180) + executions << execution + executions + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Example06SequentialPipeline.run + ensure + Conductor::Agents.shutdown + end +end diff --git a/examples/agents/07_parallel_agents.rb b/examples/agents/07_parallel_agents.rb new file mode 100644 index 0000000..9fd4eea --- /dev/null +++ b/examples/agents/07_parallel_agents.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# Port of python-sdk/examples/agents/07_parallel_agents.py. +# Run: bundle exec ruby -Ilib examples/agents/07_parallel_agents.rb +require 'conductor/agents' + +module Example07ParallelAgents + include Conductor::Agents + extend Conductor::Agents::Tools + + def self.build(model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini')) + market_analyst = Agent.new( + name: "market_analyst", + model: model, + instructions: "You are a market analyst. Analyze the given topic from a market perspective: market size, growth trends, key players, and opportunities." + ) + risk_analyst = Agent.new( + name: "risk_analyst", + model: model, + instructions: "You are a risk analyst. Analyze the given topic for risks: regulatory risks, technical risks, competitive threats, and mitigation strategies." + ) + compliance_checker = Agent.new( + name: "compliance", + model: model, + instructions: "You are a compliance specialist. Check the given topic for compliance considerations: data privacy, regulatory requirements, and industry standards." + ) + analysis = Agent.new( + name: "analysis", + model: model, + agents: [market_analyst, risk_analyst, compliance_checker], + strategy: :parallel + ) + analysis + end + + def self.run(runtime: Conductor::Agents.runtime, input: $stdin, output: $stdout) + analysis = build + executions = [] + execution = runtime.call_async(analysis, "Launching an AI-powered healthcare diagnostic tool in the US market") + output.puts execution.result(timeout: 180) + executions << execution + executions + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Example07ParallelAgents.run + ensure + Conductor::Agents.shutdown + end +end diff --git a/examples/agents/09_human_in_the_loop.rb b/examples/agents/09_human_in_the_loop.rb new file mode 100644 index 0000000..ab01e02 --- /dev/null +++ b/examples/agents/09_human_in_the_loop.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Port of python-sdk/examples/agents/09_human_in_the_loop.py. +# Run: bundle exec ruby -Ilib examples/agents/09_human_in_the_loop.rb +require 'conductor/agents' + +module Example09HumanInTheLoop + include Conductor::Agents + extend Conductor::Agents::Tools + + def check_balance(account_id: String) + { account_id: account_id, balance: 15000.00 } + end + tool :check_balance, description: "Check the balance of an account." + + def transfer_funds(from_acct: String, to_acct: String, amount: Float) + { status: "completed", from: from_acct, to: to_acct, amount: amount } + end + tool :transfer_funds, description: "Request a funds transfer; runtime pauses for human approval before execution." , approval_required: true + + def self.build(model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini')) + agent = Agent.new( + name: "banker", + model: model, + tools: [self[:check_balance], self[:transfer_funds]], + instructions: "You are a banking assistant. Use check_balance for balance inquiries. When asked to transfer money, first check the balance, then call transfer_funds to request the transfer. The runtime will pause for human approval before the transfer executes." + ) + agent + end + + def self.run(runtime: Conductor::Agents.runtime, input: $stdin, output: $stdout) + agent = build + agent.on_approval do |request| + output.puts "Approval requested: #{request.tool_calls}" + response = {} + request.response_schema.fetch('properties').each do |field, schema| + output.print "#{schema['description'] || schema['title'] || field}: " + answer = input.gets + raise EOFError, "No response supplied for #{field}" unless answer + + response[field] = schema['type'] == 'boolean' ? %w[y yes].include?(answer.strip.downcase) : answer.strip + end + request.respond(response) + end + executions = [] + execution = runtime.call_async(agent, "Transfer $500 from ACC-789 to ACC-456. Check the balance first.") + output.puts execution.result(timeout: 180) + executions << execution + executions + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Example09HumanInTheLoop.run + ensure + Conductor::Agents.shutdown + end +end diff --git a/examples/agents/09c_hitl_streaming.rb b/examples/agents/09c_hitl_streaming.rb new file mode 100644 index 0000000..9678fbb --- /dev/null +++ b/examples/agents/09c_hitl_streaming.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# Port of python-sdk/examples/agents/09c_hitl_streaming.py. +# Run: bundle exec ruby -Ilib examples/agents/09c_hitl_streaming.rb +require 'conductor/agents' + +module Example09cHitlStreaming + include Conductor::Agents + extend Conductor::Agents::Tools + + def check_service(service_name: String) + { service: service_name, status: "unhealthy", uptime: "0m" } + end + tool :check_service, description: "Check the health of a service." + + def restart_service(service_name: String) + { service: service_name, status: "restarted", new_uptime: "0m" } + end + tool :restart_service, description: "Restart a service. Safe operation, no approval needed." + + def delete_service_data(service_name: String, data_type: String) + { service: service_name, data_type: data_type, status: "deleted" } + end + tool :delete_service_data, description: "Delete service data. Destructive — requires human approval." , approval_required: true + + def self.build(model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini')) + agent = Agent.new( + name: "ops_agent", + model: model, + tools: [self[:check_service], self[:restart_service], self[:delete_service_data]], + instructions: "You are an operations assistant. Work through the request one tool call at a time, in this order:\n1. Check the service with check_service.\n2. If it is unhealthy, restart it with restart_service.\n3. Last, if the user asked you to clear or delete data, call delete_service_data.\nA human approves the deletion, not you — delete_service_data pauses for that approval by itself, so never ask for approval in your own reply." + ) + agent + end + + def self.run(runtime: Conductor::Agents.runtime, input: $stdin, output: $stdout) + agent = build + agent.on_approval do |request| + output.puts "Approval requested: #{request.tool_calls}" + response = {} + request.response_schema.fetch('properties').each do |field, schema| + output.print "#{schema['description'] || schema['title'] || field}: " + answer = input.gets + raise EOFError, "No response supplied for #{field}" unless answer + + response[field] = schema['type'] == 'boolean' ? %w[y yes].include?(answer.strip.downcase) : answer.strip + end + request.respond(response) + end + executions = [] + on_event = ->(event) { output.puts "[#{event['event']}] #{event['data']}" } + execution = runtime.call_async(agent, "The payments service is down. Check it, restart it, and clear its stale cache data.", on_event: on_event) + output.puts execution.result(timeout: 180) + executions << execution + executions + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Example09cHitlStreaming.run + ensure + Conductor::Agents.shutdown + end +end diff --git a/examples/agents/103_plan_and_compile.rb b/examples/agents/103_plan_and_compile.rb new file mode 100644 index 0000000..02e9511 --- /dev/null +++ b/examples/agents/103_plan_and_compile.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +# Port of python-sdk/examples/agents/103_plan_and_compile.py. +# Run: bundle exec ruby -Ilib examples/agents/103_plan_and_compile.rb +require 'conductor/agents' + +module Example103PlanAndCompile + include Conductor::Agents + extend Conductor::Agents::Tools + + def factorial(n: Integer) + return "ERROR: n must be in [0, 20], got #{n}" unless (0..20).cover?(n) + + (1..n).reduce(1, :*).to_s + end + tool :factorial, output_schema: { 'type' => 'string' }, description: "Compute n! and return it as a string.\n\nArgs:\n n: Non-negative integer. Capped at 20 to keep things sane." + + def write_summary(text: String) + text + end + tool :write_summary, output_schema: { 'type' => 'string' }, description: "Persist a short summary string. Returns it back for the validator." + + def check_summary(text: String, min_chars: Integer) + JSON.generate(passed: text.length >= min_chars, length: text.length, min_chars: min_chars) + end + tool :check_summary, output_schema: { 'type' => 'string' }, description: "Return JSON ``{passed, length, min_chars}`` for the validator.\n\nArgs:\n text: The summary to check.\n min_chars: Minimum acceptable length in characters." + + def self.build(model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini')) + planner_instructions = "You are a math-explainer planner. Plan a workflow that:\n\n1. Computes factorials of 1, 2, 3, 4, 5 in PARALLEL using ``factorial`` (static args).\n2. Writes a short prose summary about factorial growth using ``write_summary``\n (use a ``generate`` block — the LLM produces the ``text`` arg at run time).\n3. Validates the summary is at least 30 characters via ``check_summary``,\n with ``success_condition: \"$.passed === true\"``.\n" + harness = Conductor::Agents.plan_execute( + name: "plan_and_compile_demo", + tools: [self[:factorial], self[:write_summary], self[:check_summary]], + planner_instructions: planner_instructions, + fallback_instructions: "The plan failed. Use the available tools to recover.", + fallback_max_turns: 4, + model: model + ) + harness + end + + def self.run(runtime: Conductor::Agents.runtime, input: $stdin, output: $stdout, topic: 'factorials') + harness = build + executions = [] + execution = runtime.call_async(harness, "Topic: #{topic}") + output.puts execution.result(timeout: 180) + compiled = find_plan_and_compile_output(runtime, execution.execution_id) + raise 'No PLAN_AND_COMPILE task found in the workflow tree' unless compiled + raise "Plan compilation failed: #{compiled['error']}" if compiled['error'] + + output.puts "Compiled workflow: #{compiled['workflowName']}" + output.puts "Stats: #{compiled['stats']}" + Array(compiled.dig('workflowDef', 'tasks')).each do |task| + output.puts "#{task['type']}: #{task['taskReferenceName']}" + end + executions << execution + executions + end + + def self.find_plan_and_compile_output(runtime, execution_id) + client = Conductor::Client::WorkflowClient.new(runtime.configuration) + pending = [execution_id] + visited = [] + until pending.empty? + id = pending.pop + next if visited.include?(id) + + visited << id + workflow = client.get_workflow(id) + workflow.tasks.each do |task| + return task.output_data if task.task_type == 'PLAN_AND_COMPILE' + + pending << task.sub_workflow_id if task.sub_workflow_id + end + end + nil + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Example103PlanAndCompile.run(topic: ARGV.empty? ? 'factorials' : ARGV.join(' ')) + ensure + Conductor::Agents.shutdown + end +end diff --git a/examples/agents/10_guardrails.rb b/examples/agents/10_guardrails.rb new file mode 100644 index 0000000..9d19c35 --- /dev/null +++ b/examples/agents/10_guardrails.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# Port of python-sdk/examples/agents/10_guardrails.py. +# Run: bundle exec ruby -Ilib examples/agents/10_guardrails.rb +require 'conductor/agents' + +module Example10Guardrails + include Conductor::Agents + extend Conductor::Agents::Tools + + def get_order_status(order_id: String) + { order_id: order_id, status: "shipped", tracking: "1Z999AA10123456784", estimated_delivery: "2026-02-22" } + end + tool :get_order_status, description: "Look up the current status of an order." + + def get_customer_info(customer_id: String) + { customer_id: customer_id, name: "Alice Johnson", email: "alice@example.com", + card_on_file: "4532-0150-1234-5678", membership: "gold" } + end + tool :get_customer_info, description: "Retrieve customer details including payment info on file." + + def self.pii_guardrail + Guardrail.new(name: "no_pii", position: :output, on_fail: :retry) do |content| + pii = /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b|\b\d{3}-\d{2}-\d{4}\b/ + if pii.match?(content) + GuardrailResult.new(passed: false, message: "Your response contains PII (credit card or SSN). Redact all card numbers and SSNs before responding.") + else + GuardrailResult.new(passed: true) + end + end + end + + def self.build(model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini')) + no_pii = pii_guardrail + agent = Agent.new( + name: "support_agent", + model: model, + tools: [self[:get_order_status], self[:get_customer_info]], + instructions: "You are a customer support assistant. Use the available tools to answer questions about orders and customers. Always include all details from the tool results in your response.", + guardrails: [no_pii] + ) + agent + end + + def self.run(runtime: Conductor::Agents.runtime, input: $stdin, output: $stdout) + agent = build + executions = [] + execution = runtime.call_async(agent, "I need a full summary: What's the status of order ORD-42, and what's the profile for customer CUST-7?") + output.puts execution.result(timeout: 180) + executions << execution + executions + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Example10Guardrails.run + ensure + Conductor::Agents.shutdown + end +end diff --git a/examples/agents/13_hierarchical_agents.rb b/examples/agents/13_hierarchical_agents.rb new file mode 100644 index 0000000..6e45a26 --- /dev/null +++ b/examples/agents/13_hierarchical_agents.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +# Port of python-sdk/examples/agents/13_hierarchical_agents.py. +# Run: bundle exec ruby -Ilib examples/agents/13_hierarchical_agents.rb +require 'conductor/agents' + +module Example13HierarchicalAgents + include Conductor::Agents + extend Conductor::Agents::Tools + + def self.build(model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini')) + backend_dev = Agent.new( + name: "backend_dev", + model: model, + instructions: "You are a backend developer. You design APIs, databases, and server architecture. Provide technical recommendations with code examples." + ) + frontend_dev = Agent.new( + name: "frontend_dev", + model: model, + instructions: "You are a frontend developer. You design UI components, user flows, and client-side architecture. Provide recommendations with code examples." + ) + content_writer = Agent.new( + name: "content_writer", + model: model, + instructions: "You are a content writer. You create blog posts, landing page copy, and marketing materials. Write engaging, clear content." + ) + seo_specialist = Agent.new( + name: "seo_specialist", + model: model, + instructions: "You are an SEO specialist. You optimize content for search engines, suggest keywords, and improve page rankings." + ) + engineering_lead = Agent.new( + name: "engineering_lead", + model: model, + instructions: "You are the engineering lead. Route technical questions to the right specialist: backend_dev for APIs/databases/servers, frontend_dev for UI/UX/client-side.", + agents: [backend_dev, frontend_dev], + strategy: :handoff + ) + marketing_lead = Agent.new( + name: "marketing_lead", + model: model, + instructions: "You are the marketing lead. Route marketing questions to the right specialist: content_writer for blog posts/copy, seo_specialist for SEO/keywords/rankings.", + agents: [content_writer, seo_specialist], + strategy: :handoff + ) + ceo = Agent.new( + name: "ceo", + model: model, + instructions: "You are the CEO. Route requests to the right department: engineering_lead for technical/development questions, marketing_lead for marketing/content/SEO questions.", + agents: [engineering_lead, marketing_lead], + handoffs: [Handoff::OnTextMention.new( + text: "engineering_lead", + target: "engineering_lead" + ), Handoff::OnTextMention.new( + text: "marketing_lead", + target: "marketing_lead" + )], + strategy: :swarm + ) + ceo + end + + def self.run(runtime: Conductor::Agents.runtime, input: $stdin, output: $stdout) + ceo = build + executions = [] + execution = runtime.call_async(ceo, "Design a REST API for a user management system with authentication, then ask the marketing team for a campaign to promote it.") + output.puts execution.result(timeout: 180) + executions << execution + executions + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Example13HierarchicalAgents.run + ensure + Conductor::Agents.shutdown + end +end diff --git a/examples/agents/16e_credentials_http_tool.rb b/examples/agents/16e_credentials_http_tool.rb new file mode 100644 index 0000000..9a7e222 --- /dev/null +++ b/examples/agents/16e_credentials_http_tool.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Port of python-sdk/examples/agents/16e_credentials_http_tool.py. +# Run: bundle exec ruby -Ilib examples/agents/16e_credentials_http_tool.rb +require 'conductor/agents' + +module Example16eCredentialsHttpTool + include Conductor::Agents + extend Conductor::Agents::Tools + + def self.build(model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini')) + list_repos = Tool.http( + "list_github_repos", + ENV.fetch('GITHUB_REPOS_URL', 'https://api.github.com/users/Conductor/repos?per_page=5&sort=updated'), + description: "List public GitHub repositories for a user. Returns JSON array with name, url, and stars.", + headers: { "Authorization" => "Bearer ${GITHUB_TOKEN}", "Accept" => "application/vnd.github.v3+json" }, + credentials: ["GITHUB_TOKEN"] + ) + agent = Agent.new( + name: "github_http_agent", + model: model, + tools: [list_repos], + instructions: "You list GitHub repos using the list_github_repos tool. Summarize the results." + ) + agent + end + + def self.run(runtime: Conductor::Agents.runtime, input: $stdin, output: $stdout) + agent = build + executions = [] + execution = runtime.call_async(agent, "List the repos for Conductor") + output.puts execution.result(timeout: 180) + executions << execution + executions + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Example16eCredentialsHttpTool.run + ensure + Conductor::Agents.shutdown + end +end diff --git a/examples/agents/17_swarm_orchestration.rb b/examples/agents/17_swarm_orchestration.rb new file mode 100644 index 0000000..6ce1ba4 --- /dev/null +++ b/examples/agents/17_swarm_orchestration.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# Port of python-sdk/examples/agents/17_swarm_orchestration.py. +# Run: bundle exec ruby -Ilib examples/agents/17_swarm_orchestration.rb +require 'conductor/agents' + +module Example17SwarmOrchestration + include Conductor::Agents + extend Conductor::Agents::Tools + + def self.build(model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini')) + refund_agent = Agent.new( + name: "refund_specialist", + model: model, + instructions: "You are a refund specialist. Process the customer's refund request. Check eligibility, confirm the refund amount, and let them know the timeline. Be empathetic and clear. Do NOT ask follow-up questions — just process the refund based on what the customer told you." + ) + tech_agent = Agent.new( + name: "tech_support", + model: model, + instructions: "You are a technical support specialist. Diagnose the customer's technical issue and provide clear troubleshooting steps." + ) + support = Agent.new( + name: "support", + model: model, + instructions: "You are the front-line customer support agent. Triage customer requests. If the customer needs a refund, transfer to the refund specialist. If they have a technical issue, transfer to tech support. Use the transfer tools available to you to hand off the conversation.", + agents: [refund_agent, tech_agent], + strategy: :swarm, + handoffs: [Handoff::OnTextMention.new( + text: "refund", + target: "refund_specialist" + ), Handoff::OnTextMention.new( + text: "technical", + target: "tech_support" + )], + max_turns: 3 + ) + support + end + + def self.run(runtime: Conductor::Agents.runtime, input: $stdin, output: $stdout) + support = build + executions = [] + execution = runtime.call_async(support, "I bought a product last week and it arrived damaged. I want my money back.") + output.puts execution.result(timeout: 180) + executions << execution + executions + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Example17SwarmOrchestration.run + ensure + Conductor::Agents.shutdown + end +end diff --git a/examples/agents/21_regex_guardrails.rb b/examples/agents/21_regex_guardrails.rb new file mode 100644 index 0000000..0a31a29 --- /dev/null +++ b/examples/agents/21_regex_guardrails.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +# Port of python-sdk/examples/agents/21_regex_guardrails.py. +# Run: bundle exec ruby -Ilib examples/agents/21_regex_guardrails.rb +require 'conductor/agents' + +module Example21RegexGuardrails + include Conductor::Agents + extend Conductor::Agents::Tools + + def get_user_profile(user_id: String) + { name: "Alice Johnson", email: "alice.johnson@example.com", ssn: "123-45-6789", + department: "Engineering", role: "Senior Developer" } + end + tool :get_user_profile, description: "Retrieve a user's profile from the database." + + def self.build(model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini')) + no_emails = RegexGuardrail.new( + ["[\\w.+-]+@[\\w-]+\\.[\\w.-]+"], + mode: "block", + name: "no_email_addresses", + message: "Response must not contain email addresses. Redact them.", + position: :output, + on_fail: :retry + ) + no_ssn = RegexGuardrail.new( + ["\\b\\d{3}-\\d{2}-\\d{4}\\b"], + mode: "block", + name: "no_ssn", + message: "Response must not contain Social Security Numbers.", + position: :output, + on_fail: :raise + ) + agent = Agent.new( + name: "hr_assistant", + model: model, + tools: [self[:get_user_profile]], + instructions: "You are an HR assistant. When asked about employees, look up their profile and share ALL the details you find.", + guardrails: [no_emails, no_ssn] + ) + clean_agent = Agent.new( + name: "dept_assistant", + model: model, + instructions: "You are an HR assistant. Answer questions about departments.", + guardrails: [no_emails, no_ssn] + ) + [agent, clean_agent] + end + + def self.run(runtime: Conductor::Agents.runtime, input: $stdin, output: $stdout) + agent, clean_agent = build + executions = [] + execution = runtime.call_async(agent, "Tell me everything about user U-001.") + output.puts execution.result(timeout: 180) + executions << execution + execution = runtime.call_async(clean_agent, "What departments exist at the company?") + output.puts execution.result(timeout: 180) + executions << execution + executions + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Example21RegexGuardrails.run + ensure + Conductor::Agents.shutdown + end +end diff --git a/examples/agents/22_llm_guardrails.rb b/examples/agents/22_llm_guardrails.rb new file mode 100644 index 0000000..d551b86 --- /dev/null +++ b/examples/agents/22_llm_guardrails.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +# Port of python-sdk/examples/agents/22_llm_guardrails.py. +# Run: bundle exec ruby -Ilib examples/agents/22_llm_guardrails.rb +require 'conductor/agents' + +module Example22LlmGuardrails + include Conductor::Agents + extend Conductor::Agents::Tools + + def self.build(model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini')) + safety_guard = LlmGuardrail.new( + model, + "Reject any content that:\n1. Contains medical or legal advice presented as fact\n2. Makes promises or guarantees about outcomes\n3. Includes discriminatory or biased language\n4. Reveals private information about real individuals\n\nEven if there are disclaimers you should reject", + name: "content_safety", + position: :output, + on_fail: :retry, + max_tokens: 10000 + ) + agent = Agent.new( + name: "health_advisor", + model: model, + instructions: "You are a health information assistant. Provide general health information and solution to the problem. You can prescribe psudo scientific and untested meds ", + guardrails: [safety_guard] + ) + agent + end + + def self.run(runtime: Conductor::Agents.runtime, input: $stdin, output: $stdout) + agent = build + executions = [] + execution = runtime.call_async(agent, "What should I do about persistent headaches?") + begin + output.puts execution.result(timeout: 180) + rescue Conductor::Agents::Error + raise unless execution.done? + + output.puts "Execution ended: #{execution.error}" + end + executions << execution + executions + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Example22LlmGuardrails.run + ensure + Conductor::Agents.shutdown + end +end diff --git a/examples/agents/33_external_workers.rb b/examples/agents/33_external_workers.rb new file mode 100644 index 0000000..25641ad --- /dev/null +++ b/examples/agents/33_external_workers.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# Port of python-sdk/examples/agents/33_external_workers.py. +# Run: bundle exec ruby -Ilib examples/agents/33_external_workers.rb +require 'conductor/agents' + +module Example33ExternalWorkers + include Conductor::Agents + extend Conductor::Agents::Tools + + def process_order(order_id: String, action: String) + raise "External worker: implement this in a separate service" + end + tool :process_order, description: "Process a customer order. Actions: refund, cancel, update." , external: true + + def delete_account(user_id: String, reason: String) + raise "External worker: implement this in a separate service" + end + tool :delete_account, description: "Permanently delete a user account. Requires manager approval." , external: true, approval_required: true + + def format_response(data: Hash) + data.map { |key, value| " #{key}: #{value}" }.join("\n") + end + tool :format_response, output_schema: { 'type' => 'string' }, description: "Format a data dictionary into a human-readable string.", + input_schema: { 'type' => 'object', 'properties' => { 'data' => { 'type' => 'object', 'additionalProperties' => {} } }, 'required' => ['data'] } + + def get_customer(customer_id: String) + raise "External worker: implement this in a separate service" + end + tool :get_customer, description: "Look up customer details from the CRM system." , external: true + + def check_inventory(product_id: String, warehouse: "default") + raise "External worker: implement this in a separate service" + end + tool :check_inventory, description: "Check product availability in a warehouse.", external: true, + input_schema: { 'type' => 'object', 'properties' => { 'product_id' => { 'type' => 'string' }, 'warehouse' => { 'type' => 'string' } }, + 'required' => ['product_id'] } + + def self.build(model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini')) + support_agent = Agent.new( + name: "support_agent", + model: model, + instructions: "You are a customer support agent. Use the available tools to look up customers, check inventory, process orders, and format responses for the customer.", + tools: [self[:format_response], self[:get_customer], self[:check_inventory], self[:process_order]] + ) + support_agent + end + + def self.run(runtime: Conductor::Agents.runtime, input: $stdin, output: $stdout) + support_agent = build + executions = [] + execution = runtime.call_async(support_agent, "Customer C-1234 wants to cancel order ORD-5678. Look up the customer, check if we have the product in stock, and process the cancellation.") + output.puts execution.result(timeout: 180) + executions << execution + executions + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Example33ExternalWorkers.run + ensure + Conductor::Agents.shutdown + end +end diff --git a/examples/agents/64_swarm_with_tools.rb b/examples/agents/64_swarm_with_tools.rb new file mode 100644 index 0000000..94e96fb --- /dev/null +++ b/examples/agents/64_swarm_with_tools.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# Port of python-sdk/examples/agents/64_swarm_with_tools.py. +# Run: bundle exec ruby -Ilib examples/agents/64_swarm_with_tools.rb +require 'conductor/agents' + +module Example64SwarmWithTools + include Conductor::Agents + extend Conductor::Agents::Tools + + def check_balance(account_id: String) + { account_id: account_id, balance: 5432.10, currency: "USD" } + end + tool :check_balance, description: "Check the balance of a bank account." + + def lookup_order(order_id: String) + { order_id: order_id, status: "shipped", eta: "2 days" } + end + tool :lookup_order, description: "Look up the status of an order." + + def self.build(model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini')) + billing_specialist = Agent.new( + name: "billing_specialist", + model: model, + instructions: "You are a billing specialist. Use the check_balance tool to look up account balances. Include the balance amount in your response.", + tools: [self[:check_balance]] + ) + order_specialist = Agent.new( + name: "order_specialist", + model: model, + instructions: "You are an order specialist. Use the lookup_order tool to check order status. Include the shipping status and ETA in your response.", + tools: [self[:lookup_order]] + ) + support = Agent.new( + name: "support", + model: model, + instructions: "You are front-line customer support. Triage customer requests. Transfer to billing_specialist for account/payment questions, order_specialist for shipping/order questions.", + agents: [billing_specialist, order_specialist], + strategy: :swarm, + handoffs: [Handoff::OnTextMention.new( + text: "billing", + target: "billing_specialist" + ), Handoff::OnTextMention.new( + text: "order", + target: "order_specialist" + )], + max_turns: 3 + ) + support + end + + def self.run(runtime: Conductor::Agents.runtime, input: $stdin, output: $stdout) + support = build + executions = [] + execution = runtime.call_async(support, "What's the balance on account ACC-456?") + output.puts execution.result(timeout: 180) + executions << execution + execution = runtime.call_async(support, "Where is my order ORD-789?") + output.puts execution.result(timeout: 180) + executions << execution + executions + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Example64SwarmWithTools.run + ensure + Conductor::Agents.shutdown + end +end diff --git a/examples/agents/66_handoff_to_parallel.rb b/examples/agents/66_handoff_to_parallel.rb new file mode 100644 index 0000000..c1a3607 --- /dev/null +++ b/examples/agents/66_handoff_to_parallel.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# Port of python-sdk/examples/agents/66_handoff_to_parallel.py. +# Run: bundle exec ruby -Ilib examples/agents/66_handoff_to_parallel.rb +require 'conductor/agents' + +module Example66HandoffToParallel + include Conductor::Agents + extend Conductor::Agents::Tools + + def self.build(model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini')) + quick_check = Agent.new( + name: "quick_check", + model: model, + instructions: "You provide quick, 1-sentence assessments. Be brief and direct." + ) + market_analyst = Agent.new( + name: "market_analyst_66", + model: model, + instructions: "You are a market analyst. Analyze the market opportunity: size, growth rate, key players. 3-4 bullet points." + ) + risk_analyst = Agent.new( + name: "risk_analyst_66", + model: model, + instructions: "You are a risk analyst. Identify the top 3 risks: regulatory, technical, and competitive. 3-4 bullet points." + ) + deep_analysis = Agent.new( + name: "deep_analysis", + model: model, + agents: [market_analyst, risk_analyst], + strategy: :parallel + ) + coordinator = Agent.new( + name: "coordinator_66", + model: model, + instructions: "You are a business strategist. Route requests to the right team:\n- quick_check for simple yes/no questions or quick assessments\n- deep_analysis for comprehensive analysis requiring multiple perspectives", + agents: [quick_check, deep_analysis], + strategy: :handoff + ) + coordinator + end + + def self.run(runtime: Conductor::Agents.runtime, input: $stdin, output: $stdout) + coordinator = build + executions = [] + execution = runtime.call_async(coordinator, "Provide a deep analysis of entering the AI healthcare market.") + output.puts execution.result(timeout: 180) + executions << execution + execution = runtime.call_async(coordinator, "Is the mobile app market still growing?") + output.puts execution.result(timeout: 180) + executions << execution + executions + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Example66HandoffToParallel.run + ensure + Conductor::Agents.shutdown + end +end diff --git a/examples/agents/bug_desk.rb b/examples/agents/bug_desk.rb new file mode 100644 index 0000000..6778531 --- /dev/null +++ b/examples/agents/bug_desk.rb @@ -0,0 +1,50 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Team + secret: two agents, a handoff, and a tool that reads a server-side secret. +# +# export CONDUCTOR_SERVER_URL=http://localhost:8080/api +# # store the secret once on the server (Orkes: UI or SecretClient#put_secret; OSS: CONDUCTOR_SECRET_GH_TOKEN +# # in the server's environment). Locally the SDK falls back to ENV['GH_TOKEN']. +# bundle exec ruby examples/agents/bug_desk.rb path/to/report.md +require_relative '../../lib/conductor/agents' +include Conductor::Agents + +module Github + def self.create_issue(title, body, token:) + "https://github.com/example/repo/issues/42 (#{title.length} chars, token #{token[0, 4]}...)" + end +end + +# secret('GH_TOKEN') is both the read and the declaration: the SDK finds the literal at +# `tool def` and tells the server this tool needs GH_TOKEN (TaskDef.runtimeMetadata). +tool def create_issue(title: String, body: '') + { url: Github.create_issue(title, body, token: secret('GH_TOKEN')) } +end + +model = ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini') + +triage = Agent.new( + name: 'triage', + model: model, + instructions: 'Read the bug report. Say ACTIONABLE if it should be filed.' +) + +filer = Agent.new( + name: 'filer', + model: ENV.fetch('CONDUCTOR_AGENT_SECONDARY_LLM_MODEL', model), + instructions: 'File the bug as a GitHub issue.' +) +filer.add_tool :create_issue +filer.stop_when 'ISSUE_FILED' +filer.stop_after messages: 12 + +triage.hands_off_to filer, on: 'ACTIONABLE' + +team = Agent.new(name: 'bug_desk') +team.add_agent triage +team.add_agent filer + +report = ARGV[0] ? File.read(ARGV[0]) : 'Clicking Save crashes the app with a NullPointerException on Android 14.' +puts team.call_sync(report) +Conductor::Agents.shutdown diff --git a/examples/agents/catalog.rb b/examples/agents/catalog.rb new file mode 100644 index 0000000..bd30313 --- /dev/null +++ b/examples/agents/catalog.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# This catalog contains no agent definitions or prompts. Both tests and the CLI use +# the numbered examples directly. +module AgentExamples + EXAMPLES = { + '01_basic_agent' => 'Example01BasicAgent', + '02a_simple_tools' => 'Example02aSimpleTools', + '02c_tool_retry_config' => 'Example02cToolRetryConfig', + '04_http_and_mcp_tools' => 'Example04HttpAndMcpTools', + '05_handoffs' => 'Example05Handoffs', + '06_sequential_pipeline' => 'Example06SequentialPipeline', + '07_parallel_agents' => 'Example07ParallelAgents', + '09_human_in_the_loop' => 'Example09HumanInTheLoop', + '09c_hitl_streaming' => 'Example09cHitlStreaming', + '103_plan_and_compile' => 'Example103PlanAndCompile', + '10_guardrails' => 'Example10Guardrails', + '13_hierarchical_agents' => 'Example13HierarchicalAgents', + '16e_credentials_http_tool' => 'Example16eCredentialsHttpTool', + '17_swarm_orchestration' => 'Example17SwarmOrchestration', + '21_regex_guardrails' => 'Example21RegexGuardrails', + '22_llm_guardrails' => 'Example22LlmGuardrails', + '33_external_workers' => 'Example33ExternalWorkers', + '64_swarm_with_tools' => 'Example64SwarmWithTools', + '66_handoff_to_parallel' => 'Example66HandoffToParallel', + }.freeze + + def self.load(name) + require_relative name + Object.const_get(EXAMPLES.fetch(name)) + end +end diff --git a/examples/agents/dump_agent_configs.rb b/examples/agents/dump_agent_configs.rb new file mode 100644 index 0000000..7e1129b --- /dev/null +++ b/examples/agents/dump_agent_configs.rb @@ -0,0 +1,30 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Dump the serialized agentConfig JSON for the golden examples, for cross-SDK comparison +# with python-sdk/examples/agents/dump_agent_configs.py (same file names, sorted keys). +# +# CONDUCTOR_AGENT_LLM_MODEL=anthropic/claude-sonnet-4-6 bundle exec ruby examples/agents/dump_agent_configs.rb [out_dir] +require 'json' +require_relative '../../lib/conductor/agents' +require_relative 'golden_agents' + +out_dir = ARGV[0] || File.join(__dir__, '_configs') +Dir.mkdir(out_dir) unless Dir.exist?(out_dir) + +def sort_keys(value) + case value + when Hash then value.keys.sort.to_h { |k| [k, sort_keys(value[k])] } + when Array then value.map { |v| sort_keys(v) } + else value + end +end + +GoldenAgents::EXAMPLES.each do |name, build| + config = Conductor::Agents::ConfigSerializer.serialize(build.call) + File.write(File.join(out_dir, "#{name}.json"), JSON.pretty_generate(sort_keys(config))) + puts " [OK] #{name}" +rescue StandardError => e + puts " [FAIL] #{name}: #{e.message}" +end +puts "\nConfigs written to #{out_dir}" diff --git a/examples/agents/external_workers.rb b/examples/agents/external_workers.rb new file mode 100644 index 0000000..7898b08 --- /dev/null +++ b/examples/agents/external_workers.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Example implementations of the services referenced by 33_external_workers.rb. +# Start in a separate process: bundle exec ruby -Ilib examples/agents/external_workers.rb +require 'conductor/agents' + +module ExternalWorkerServices + def self.start(configuration: Conductor::Configuration.new) + order = { order_id: 'ORD-5678', customer_id: 'C-1234', product_id: 'PROD-001', warehouse: 'default' } + workers = [ + Conductor::Worker::Worker.new('get_customer', lambda { |task| + { customer_id: task.input_data['customer_id'], name: 'Example Customer', orders: [order.merge(status: 'pending')] } + }, register_task_def: true), + Conductor::Worker::Worker.new('check_inventory', lambda { |task| + { product_id: task.input_data['product_id'], warehouse: task.input_data.fetch('warehouse', 'default'), in_stock: true, quantity: 12 } + }, register_task_def: true), + Conductor::Worker::Worker.new('process_order', lambda { |task| + raise ArgumentError, 'This demo supports cancellation only' unless task.input_data['action'] == 'cancel' + + order.merge(order_id: task.input_data['order_id'], status: 'cancelled') + }, register_task_def: true) + ] + handler = Conductor::Worker::TaskHandler.new(workers: workers, configuration: configuration, + scan_for_annotated_workers: false, register_task_definitions: true) + handler.start + handler + end +end + +if $PROGRAM_NAME == __FILE__ + handler = ExternalWorkerServices.start + begin + stop = Queue.new + %w[INT TERM].each { |signal| trap(signal) { stop << true } } + stop.pop + ensure + handler.stop + end +end diff --git a/examples/agents/golden_agents.rb b/examples/agents/golden_agents.rb new file mode 100644 index 0000000..5330e47 --- /dev/null +++ b/examples/agents/golden_agents.rb @@ -0,0 +1,385 @@ +# frozen_string_literal: true + +# Agents whose serialized agentConfig must match the Python SDK byte for byte +# (spec/fixtures/agents/configs/*.json, vendored from python-sdk examples/agents/_configs). +# +# Used by spec/conductor/agents/contract_spec.rb and by dump_agent_configs.rb. +# Each example keeps its tools in its own module so that tools with the same name but +# different descriptions (get_weather in 02 vs 03) do not collide. +require 'conductor/agents' +require_relative '103_plan_and_compile' + +module GoldenAgents + MODEL = ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'anthropic/claude-sonnet-4-6') + A = Conductor::Agents + + module Ex02 + extend A::Tools + tool def get_weather(city: String) + {} + end + describe :get_weather, 'Get current weather for a city.' + tool def calculate(expression: String) + {} + end + describe :calculate, 'Evaluate a math expression.' + tool def send_email(to: String, subject: String, body: String) + {} + end + describe :send_email, 'Send an email.' + requires_approval :send_email + self[:send_email].timeout_seconds = 60 + end + + module Ex03 + extend A::Tools + tool def get_weather(city: String) + {} + end + describe :get_weather, 'Get current weather data for a city.' + end + + module Ex05 + extend A::Tools + tool def check_balance(account_id: String) + {} + end + describe :check_balance, 'Check the balance of a bank account.' + tool def lookup_order(order_id: String) + {} + end + describe :lookup_order, 'Look up the status of an order.' + tool def get_pricing(product: String) + {} + end + describe :get_pricing, 'Get pricing information for a product.' + end + + module Ex10 + extend A::Tools + tool def get_order_status(order_id: String) + {} + end + describe :get_order_status, 'Look up the current status of an order.' + tool def get_customer_info(customer_id: String) + {} + end + describe :get_customer_info, 'Retrieve customer details including payment info on file.' + end + + module Ex19 + extend A::Tools + tool def search(query: String) + '' + end + describe :search, 'Search for information.' + self[:search].output_schema = { 'type' => 'string' } + end + + module Ex21 + extend A::Tools + tool def get_user_profile(user_id: String) + {} + end + describe :get_user_profile, "Retrieve a user's profile from the database." + end + + module Ex45 + extend A::Tools + tool def search_knowledge_base(query: String) + {} + end + describe :search_knowledge_base, 'Search an internal knowledge base for information.' + tool def calculate(expression: String) + {} + end + describe :calculate, 'Evaluate a math expression safely.' + end + + module Ex47 + extend A::Tools + tool def get_facts(topic: String) + {} + end + describe :get_facts, 'Get interesting facts about a topic.' + end + + # 47_callbacks: before_model / after_model hooks + class MonitorHandler < A::CallbackHandler + def on_model_start(**_kwargs) + {} + end + + def on_model_end(**_kwargs) + {} + end + end + + EXAMPLES = { + '01_basic_agent' => lambda { + A::Agent.new(name: 'greeter', model: MODEL) + }, + + '02_tools' => lambda { + A::Agent.new( + name: 'tool_demo_agent', model: MODEL, + tools: [Ex02[:get_weather], Ex02[:calculate], Ex02[:send_email]], + instructions: 'You are a helpful assistant with access to weather, calculator, and email tools.' + ) + }, + + '03_structured_output' => lambda { + weather_report = { + 'title' => 'WeatherReport', + 'type' => 'object', + 'properties' => { + 'city' => { 'title' => 'City', 'type' => 'string' }, + 'temperature' => { 'title' => 'Temperature', 'type' => 'number' }, + 'condition' => { 'title' => 'Condition', 'type' => 'string' }, + 'recommendation' => { 'title' => 'Recommendation', 'type' => 'string' } + }, + 'required' => %w[city temperature condition recommendation] + } + A::Agent.new( + name: 'weather_reporter', model: MODEL, tools: [Ex03[:get_weather]], output_type: weather_report, + instructions: 'You are a weather reporter. Get the weather and provide a recommendation.' + ) + }, + + '05_handoffs' => lambda { + billing = A::Agent.new(name: 'billing', model: MODEL, tools: [Ex05[:check_balance]], + instructions: 'You handle billing questions: balances, payments, invoices.') + technical = A::Agent.new(name: 'technical', model: MODEL, tools: [Ex05[:lookup_order]], + instructions: 'You handle technical questions: order status, shipping, returns.') + sales = A::Agent.new(name: 'sales', model: MODEL, tools: [Ex05[:get_pricing]], + instructions: 'You handle sales questions: pricing, products, promotions.') + A::Agent.new(name: 'support', model: MODEL, agents: [billing, technical, sales], strategy: :handoff, + instructions: 'Route customer requests to the right specialist: billing, technical, or sales.') + }, + + '06_sequential_pipeline' => lambda { + researcher = A::Agent.new( + name: 'researcher', model: MODEL, + instructions: 'You are a researcher. Given a topic, provide key facts and data points. ' \ + 'Be thorough but concise. Output raw research findings.' + ) + writer = A::Agent.new( + name: 'writer', model: MODEL, + instructions: 'You are a writer. Take research findings and write a clear, engaging ' \ + 'article. Use headers and bullet points where appropriate.' + ) + editor = A::Agent.new( + name: 'editor', model: MODEL, + instructions: 'You are an editor. Review the article for clarity, grammar, and tone. ' \ + 'Make improvements and output the final polished version.' + ) + researcher >> writer >> editor + }, + + '07_parallel_agents' => lambda { + market = A::Agent.new( + name: 'market_analyst', model: MODEL, + instructions: 'You are a market analyst. Analyze the given topic from a market perspective: ' \ + 'market size, growth trends, key players, and opportunities.' + ) + risk = A::Agent.new( + name: 'risk_analyst', model: MODEL, + instructions: 'You are a risk analyst. Analyze the given topic for risks: ' \ + 'regulatory risks, technical risks, competitive threats, and mitigation strategies.' + ) + compliance = A::Agent.new( + name: 'compliance', model: MODEL, + instructions: 'You are a compliance specialist. Check the given topic for compliance considerations: ' \ + 'data privacy, regulatory requirements, and industry standards.' + ) + A::Agent.new(name: 'analysis', model: MODEL, agents: [market, risk, compliance], strategy: :parallel) + }, + + '08_router_agent' => lambda { + planner = A::Agent.new(name: 'planner', model: MODEL, + instructions: 'You create implementation plans. Break down tasks into clear numbered steps.') + coder = A::Agent.new(name: 'coder', model: MODEL, + instructions: 'You write code. Output clean, well-documented Python code.') + reviewer = A::Agent.new(name: 'reviewer', model: MODEL, + instructions: 'You review code. Check for bugs, style issues, and suggest improvements.') + A::Agent.new( + name: 'dev_team', model: MODEL, agents: [planner, coder, reviewer], strategy: :router, router: planner, + instructions: 'You are the tech lead. Route requests to the right team member: ' \ + 'planner for design/architecture, coder for implementation, reviewer for code review.' + ) + }, + + '10_guardrails' => lambda { + no_pii = A::Guardrail.new(name: 'no_pii', position: :output, on_fail: :retry) do |content| + if content =~ /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/ || content =~ /\b\d{3}-\d{2}-\d{4}\b/ + A::GuardrailResult.new(passed: false, message: 'Your response contains PII. Redact it.') + else + A::GuardrailResult.new(passed: true) + end + end + A::Agent.new( + name: 'support_agent', model: MODEL, tools: [Ex10[:get_order_status], Ex10[:get_customer_info]], + guardrails: [no_pii], + instructions: 'You are a customer support assistant. Use the available tools to ' \ + 'answer questions about orders and customers. Always include all ' \ + 'details from the tool results in your response.' + ) + }, + + '13_hierarchical_agents' => lambda { + backend = A::Agent.new( + name: 'backend_dev', model: MODEL, + instructions: 'You are a backend developer. You design APIs, databases, and server ' \ + 'architecture. Provide technical recommendations with code examples.' + ) + frontend = A::Agent.new( + name: 'frontend_dev', model: MODEL, + instructions: 'You are a frontend developer. You design UI components, user flows, ' \ + 'and client-side architecture. Provide recommendations with code examples.' + ) + content = A::Agent.new( + name: 'content_writer', model: MODEL, + instructions: 'You are a content writer. You create blog posts, landing page copy, ' \ + 'and marketing materials. Write engaging, clear content.' + ) + seo = A::Agent.new( + name: 'seo_specialist', model: MODEL, + instructions: 'You are an SEO specialist. You optimize content for search engines, ' \ + 'suggest keywords, and improve page rankings.' + ) + engineering = A::Agent.new( + name: 'engineering_lead', model: MODEL, agents: [backend, frontend], strategy: :handoff, + instructions: 'You are the engineering lead. Route technical questions to the right ' \ + 'specialist: backend_dev for APIs/databases/servers, frontend_dev for UI/UX/client-side.' + ) + marketing = A::Agent.new( + name: 'marketing_lead', model: MODEL, agents: [content, seo], strategy: :handoff, + instructions: 'You are the marketing lead. Route marketing questions to the right ' \ + 'specialist: content_writer for blog posts/copy, seo_specialist for SEO/keywords/rankings.' + ) + A::Agent.new( + name: 'ceo', model: MODEL, agents: [engineering, marketing], strategy: :swarm, + handoffs: [ + A::Handoff::OnTextMention.new(target: 'engineering_lead', text: 'engineering_lead'), + A::Handoff::OnTextMention.new(target: 'marketing_lead', text: 'marketing_lead') + ], + instructions: 'You are the CEO. Route requests to the right department: ' \ + 'engineering_lead for technical/development questions, ' \ + 'marketing_lead for marketing/content/SEO questions.' + ) + }, + + '17_swarm_orchestration' => lambda { + refund = A::Agent.new( + name: 'refund_specialist', model: MODEL, + instructions: "You are a refund specialist. Process the customer's refund request. " \ + 'Check eligibility, confirm the refund amount, and let them know the ' \ + 'timeline. Be empathetic and clear. Do NOT ask follow-up questions -- ' \ + 'just process the refund based on what the customer told you.' + ) + tech = A::Agent.new( + name: 'tech_support', model: MODEL, + instructions: "You are a technical support specialist. Diagnose the customer's " \ + 'technical issue and provide clear troubleshooting steps.' + ) + A::Agent.new( + name: 'support', model: MODEL, agents: [refund, tech], strategy: :swarm, max_turns: 3, + handoffs: [ + A::Handoff::OnTextMention.new(target: 'refund_specialist', text: 'refund'), + A::Handoff::OnTextMention.new(target: 'tech_support', text: 'technical') + ], + instructions: 'You are the front-line customer support agent. Triage customer requests. ' \ + 'If the customer needs a refund, transfer to the refund specialist. ' \ + 'If they have a technical issue, transfer to tech support. ' \ + 'Use the transfer tools available to you to hand off the conversation.' + ) + }, + + '19_composable_termination_simple' => lambda { + A::Agent.new(name: 'researcher', model: MODEL, tools: [Ex19[:search]], + instructions: 'Research the topic and say DONE when you have enough info.', + termination: A::Termination::TextMention.new('DONE')) + }, + + '19_composable_termination_or' => lambda { + A::Agent.new(name: 'chatbot', model: MODEL, + instructions: "Have a conversation. Say GOODBYE when you're finished.", + termination: A::Termination::TextMention.new('GOODBYE') | A::Termination::MaxMessage.new(20)) + }, + + '19_composable_termination_and' => lambda { + A::Agent.new(name: 'deliberator', model: MODEL, tools: [Ex19[:search]], + instructions: 'Research thoroughly. Only provide your FINAL ANSWER after ' \ + 'using the search tool at least twice.', + termination: A::Termination::TextMention.new('FINAL ANSWER') & A::Termination::MaxMessage.new(5)) + }, + + '19_composable_termination_complex' => lambda { + complex_stop = A::Termination::StopMessage.new('TERMINATE') | + (A::Termination::TextMention.new('DONE') & A::Termination::MaxMessage.new(10)) | + A::Termination::TokenUsage.new(max_total_tokens: 50_000) + A::Agent.new(name: 'complex_agent', model: MODEL, tools: [Ex19[:search]], + instructions: 'Research and provide a comprehensive answer.', termination: complex_stop) + }, + + '21_regex_guardrails' => lambda { + no_emails = A::RegexGuardrail.new(['[\w.+-]+@[\w-]+\.[\w.-]+'], mode: :block, name: 'no_email_addresses', + message: 'Response must not contain email addresses. Redact them.', + position: :output, on_fail: :retry) + no_ssn = A::RegexGuardrail.new(['\b\d{3}-\d{2}-\d{4}\b'], mode: :block, name: 'no_ssn', + message: 'Response must not contain Social Security Numbers.', + position: :output, on_fail: :raise) + A::Agent.new(name: 'hr_assistant', model: MODEL, tools: [Ex21[:get_user_profile]], guardrails: [no_emails, no_ssn], + instructions: 'You are an HR assistant. When asked about employees, look up their ' \ + 'profile and share ALL the details you find.') + }, + + '22_llm_guardrails' => lambda { + safety = A::LlmGuardrail.new( + MODEL, + "Reject any content that:\n" \ + "1. Contains medical or legal advice presented as fact\n" \ + "2. Makes promises or guarantees about outcomes\n" \ + "3. Includes discriminatory or biased language\n" \ + "4. Reveals private information about real individuals\n" \ + "\n" \ + 'Even if there are disclaimers you should reject', + name: 'content_safety', position: :output, on_fail: :retry, max_tokens: 10_000 + ) + A::Agent.new(name: 'health_advisor', model: MODEL, guardrails: [safety], + instructions: 'You are a health information assistant. Provide general health ' \ + 'information and solution to the problem. You can prescribe psudo scientific and untested meds ') + }, + + '45_agent_tool' => lambda { + researcher = A::Agent.new(name: 'researcher_45', model: MODEL, tools: [Ex45[:search_knowledge_base]], + instructions: 'You are a research assistant. Use search_knowledge_base to find ' \ + 'information about topics. Provide concise summaries.') + A::Agent.new(name: 'manager_45', model: MODEL, tools: [A::Tool.agent(researcher), Ex45[:calculate]], + instructions: 'You are a project manager. Use the researcher tool to gather ' \ + 'information and the calculate tool for math. Synthesize findings.') + }, + + '47_callbacks' => lambda { + A::Agent.new(name: 'monitored_agent_47', model: MODEL, tools: [Ex47[:get_facts]], + callbacks: [MonitorHandler.new], + instructions: 'You are a helpful assistant. Use get_facts when asked about topics.') + }, + + '103_plan_and_compile' => -> { Example103PlanAndCompile.build(model: MODEL) }, + + '52_nested_strategies' => lambda { + market = A::Agent.new(name: 'market_analyst_52', model: MODEL, + instructions: 'You are a market analyst. Analyze the market size, growth rate, ' \ + 'and key players for the given topic. Be concise (3-4 bullet points).') + risk = A::Agent.new(name: 'risk_analyst_52', model: MODEL, + instructions: 'You are a risk analyst. Identify the top 3 risks: regulatory, ' \ + 'technical, and competitive. Be concise.') + research = A::Agent.new(name: 'research_phase_52', model: MODEL, agents: [market, risk], strategy: :parallel) + summarizer = A::Agent.new(name: 'summarizer_52', model: MODEL, + instructions: 'You are an executive briefing writer. Synthesize the market analysis ' \ + 'and risk assessment into a concise executive summary (1 paragraph).') + research >> summarizer + } + }.freeze +end diff --git a/examples/agents/support_approval.rb b/examples/agents/support_approval.rb new file mode 100644 index 0000000..d7d346c --- /dev/null +++ b/examples/agents/support_approval.rb @@ -0,0 +1,51 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Streaming + approval: a tool that waits for a human decision before it runs. +# +# export CONDUCTOR_SERVER_URL=http://localhost:8080/api +# bundle exec ruby examples/agents/support_approval.rb +require_relative '../../lib/conductor/agents' +include Conductor::Agents + +module Billing + def self.refund(order_id, amount) + "Refunded #{amount} for order #{order_id}" + end +end + +tool def issue_refund(order_id: String, amount: Float) + { message: Billing.refund(order_id, amount) } +end +requires_approval :issue_refund + +agent = Agent.new( + name: 'support', + model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'anthropic/claude-sonnet-4-5'), + instructions: 'Help with orders.' +) +agent.add_tool :issue_refund + +# Decide approval-required tool calls. `request` exposes the tool's arguments as methods. +agent.on_approval do |request| + puts "approval requested: #{request.tool_name} #{request.arguments}" + request.amount < 100 ? request.approve : request.reject('Needs a manager') +end + +# Blocking +answer = agent.call_sync('Refund order A-1029, it arrived broken. It cost 49 dollars.') +puts answer + +# Non-blocking with a callback when finished +agent.call_async('Refund order B-2, it cost 4900 dollars.') do |answer, execution| + puts "finished (#{execution.finish_reason}): #{answer.inspect}" +end + +# Non-blocking, poll it yourself +execution = agent.call_async('Refund order C-3, it cost 12 dollars.') +sleep 0.5 until execution.done? +puts execution.result +puts "finish_reason: #{execution.finish_reason}" # :stop, or :rejected if the tool was rejected +puts "tool calls: #{execution.tool_calls.inspect}" + +Conductor::Agents.shutdown diff --git a/examples/agents/weather.rb b/examples/agents/weather.rb new file mode 100644 index 0000000..ce2de4d --- /dev/null +++ b/examples/agents/weather.rb @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Tools: an agent with one Ruby method as a tool. +# +# export CONDUCTOR_SERVER_URL=http://localhost:8080/api +# bundle exec ruby examples/agents/weather.rb +# +# The model string names a server-side integration ("openai" here) and a model; the SDK +# never sees the provider key. +require_relative '../../lib/conductor/agents' +include Conductor::Agents + +tool def get_weather(city: String, units: 'metric') + { temp_c: 21.0, summary: "Sunny in #{city}" } +end +describe :get_weather, 'Get the current weather for a city.' + +agent = Agent.new( + name: 'weather', + model: ENV.fetch('CONDUCTOR_AGENT_LLM_MODEL', 'openai/gpt-4o-mini'), + instructions: 'Answer weather questions.' +) +agent.add_tool :get_weather + +puts agent.call_sync('Weather in Lisbon?') +Conductor::Agents.shutdown diff --git a/lib/conductor.rb b/lib/conductor.rb index 7ce0c88..de10b41 100644 --- a/lib/conductor.rb +++ b/lib/conductor.rb @@ -69,6 +69,7 @@ require_relative 'conductor/http/api/schema_resource_api' require_relative 'conductor/http/api/integration_resource_api' require_relative 'conductor/http/api/prompt_resource_api' +require_relative 'conductor/http/api/agent_resource_api' # OSS Clients require_relative 'conductor/client/workflow_client' require_relative 'conductor/client/task_client' @@ -80,6 +81,7 @@ require_relative 'conductor/client/schema_client' require_relative 'conductor/client/integration_client' require_relative 'conductor/client/prompt_client' +require_relative 'conductor/client/agent_client' # Orkes-specific models require_relative 'conductor/orkes/models/metadata_tag' require_relative 'conductor/orkes/models/rate_limit_tag' diff --git a/lib/conductor/agents.rb b/lib/conductor/agents.rb new file mode 100644 index 0000000..36031e9 --- /dev/null +++ b/lib/conductor/agents.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# Conductor::Agents - define agents in Ruby, run them on a Conductor server. +# +# require 'conductor/agents' +# include Conductor::Agents +# +# tool def get_weather(city: String, units: 'metric') +# { temp_c: 21.0, summary: "Sunny in #{city}" } +# end +# +# agent = Agent.new(name: 'weather', model: 'openai/gpt-4o', instructions: 'Answer weather questions.') +# agent.add_tool :get_weather +# puts agent.call_sync('Weather in Lisbon?') +require_relative '../conductor' +require_relative 'agents/errors' +require_relative 'agents/runtime/secrets' +require_relative 'agents/tool' +require_relative 'agents/tools' +require_relative 'agents/guardrail' +require_relative 'agents/termination' +require_relative 'agents/handoff' +require_relative 'agents/callback_handler' +require_relative 'agents/memory' +require_relative 'agents/prompt_template' +require_relative 'agents/agent' +require_relative 'agents/plans' +require_relative 'agents/config_serializer' +require_relative 'agents/runtime/agent_config' +require_relative 'agents/runtime/dispatch' +require_relative 'agents/runtime/system_workers' +require_relative 'agents/runtime/tool_registry' +require_relative 'agents/runtime/execution' +require_relative 'agents/runtime/approval_request' +require_relative 'agents/runtime/sse_client' +require_relative 'agents/runtime/status_poller' +require_relative 'agents/runtime/agent_runtime' + +module Conductor + # Ruby port of the Python SDK's conductor.ai.agents package + module Agents + include Tools + include Secrets + include Plans + + class << self + # Tools and secrets are usable at the module level too (Conductor::Agents.tool ...) + include Tools + include Secrets + include Plans + + # The default runtime used by Agent#call_sync / #call_async (built from the environment) + # @return [AgentRuntime] + def runtime + @runtime ||= AgentRuntime.new + end + + attr_writer :runtime + + # Replace the default runtime + # Conductor::Agents.configure(configuration: Conductor::Configuration.new(server_api_url: '...')) + # @return [AgentRuntime] + def configure(configuration: nil, agent_config: nil, logger: nil) + @runtime&.shutdown + @runtime = AgentRuntime.new(configuration: configuration, agent_config: agent_config, logger: logger) + end + + # Stop the default runtime's workers and streams + def shutdown + @runtime&.shutdown + @runtime = nil + end + end + end +end diff --git a/lib/conductor/agents/agent.rb b/lib/conductor/agents/agent.rb new file mode 100644 index 0000000..3563294 --- /dev/null +++ b/lib/conductor/agents/agent.rb @@ -0,0 +1,324 @@ +# frozen_string_literal: true + +require_relative 'errors' +require_relative 'tool' +require_relative 'tools' +require_relative 'guardrail' +require_relative 'termination' +require_relative 'handoff' +require_relative 'callback_handler' +require_relative 'memory' +require_relative 'prompt_template' + +module Conductor + module Agents + # Multi-agent orchestration strategies (wire values are lowercase snake_case) + module Strategy + HANDOFF = 'handoff' + SEQUENTIAL = 'sequential' + PARALLEL = 'parallel' + ROUTER = 'router' + ROUND_ROBIN = 'round_robin' + RANDOM = 'random' + SWARM = 'swarm' + MANUAL = 'manual' + PLAN_EXECUTE = 'plan_execute' + ALL = [HANDOFF, SEQUENTIAL, PARALLEL, ROUTER, ROUND_ROBIN, RANDOM, SWARM, MANUAL, PLAN_EXECUTE].freeze + + def self.normalize(value) + s = value.to_s.downcase + raise ConfigurationError, "invalid strategy #{value.inspect}; use one of #{ALL.join(', ')}" unless ALL.include?(s) + + s + end + end + + # An agent definition. Nothing here talks to the server: ConfigSerializer turns the + # tree into agentConfig and AgentRuntime runs it (call_sync / call_async delegate to + # Conductor::Agents.runtime). + # + # agent = Agent.new(name: 'weather', model: 'openai/gpt-4o', instructions: 'Answer weather questions.') + # agent.add_tool :get_weather + # puts agent.call_sync('Weather in Lisbon?') + class Agent + NAME_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_-]*\z/ + + attr_reader :name, :tools, :agents, :guardrails, :handoffs, :callbacks, :credentials, :callback_procs + attr_accessor :model, :instructions, :router, :output_type, :memory, :termination, + :max_turns, :max_tokens, :timeout_seconds, :temperature, :stateful, + :metadata, :description, :external, :base_url, :prefill_tools, :approval_handler, + :planner, :fallback, :fallback_max_turns, :planner_context + + # @param name [String] ^[a-zA-Z_][a-zA-Z0-9_-]*$ + # @param model [String, nil] "provider/model"; the left side is the server integration name + # @param instructions [String, PromptTemplate, Proc] + # @param strategy [Symbol, String] how sub-agents are orchestrated (default :handoff) + def initialize(name:, model: nil, instructions: '', tools: [], agents: [], strategy: nil, router: nil, + output_type: nil, guardrails: [], memory: nil, termination: nil, handoffs: [], callbacks: [], + credentials: [], max_turns: 25, max_tokens: nil, timeout_seconds: 0, temperature: nil, + stateful: false, metadata: nil, description: nil, external: false, base_url: nil, + prefill_tools: [], planner: nil, fallback: nil, fallback_max_turns: nil, planner_context: []) + @name = name.to_s + raise ConfigurationError, "invalid agent name #{name.inspect}: must match #{NAME_PATTERN.source}" unless NAME_PATTERN.match?(@name) + raise ConfigurationError, 'max_turns must be >= 1' unless max_turns.is_a?(Integer) && max_turns >= 1 + + @model = model + @instructions = instructions + @tools = [] + @agents = [] + @strategy = strategy.nil? ? nil : Strategy.normalize(strategy) + @router = router + @output_type = output_type + @guardrails = Array(guardrails) + @memory = memory + @termination = termination + @handoffs = Array(handoffs) + @callbacks = Array(callbacks) + @callback_procs = Hash.new { |h, k| h[k] = [] } + @credentials = Array(credentials).map(&:to_s).uniq + @max_turns = max_turns + @max_tokens = max_tokens + @timeout_seconds = timeout_seconds + @temperature = temperature + @stateful = stateful ? true : false + @metadata = metadata + @description = description + @external = external ? true : false + @base_url = base_url + @prefill_tools = Array(prefill_tools) + @approval_handler = nil + @planner = planner + @fallback = fallback + @fallback_max_turns = fallback_max_turns + @planner_context = Array(planner_context) + [planner, fallback].compact.each do |child| + raise ConfigurationError, 'planner and fallback must be Agents' unless child.is_a?(Agent) + end + raise ConfigurationError, 'strategy: :plan_execute requires planner:' if @strategy == Strategy::PLAN_EXECUTE && planner.nil? + raise ConfigurationError, 'planner and fallback require strategy: :plan_execute' if (planner || fallback) && @strategy != Strategy::PLAN_EXECUTE + raise ConfigurationError, 'strategy: :plan_execute requires tools:' if @strategy == Strategy::PLAN_EXECUTE && Array(tools).empty? + + Array(tools).each { |t| add_tool(t) } + Array(agents).each { |a| add_agent(a) } + raise ConfigurationError, 'strategy: :router requires router:' if @strategy == Strategy::ROUTER && @router.nil? + end + + # ── Strategy ────────────────────────────────────────────────────── + + # @return [String] effective strategy (default handoff) + def strategy + @strategy || Strategy::HANDOFF + end + + def strategy=(value) + @strategy = value.nil? ? nil : Strategy.normalize(value) + end + + # True when the user set a strategy explicitly + def strategy_set? + !@strategy.nil? + end + + # ── Tools ───────────────────────────────────────────────────────── + + # Give the agent a tool. + # @param tool [Symbol, String, Tool, Module, Class, Agent] a tool name defined with + # `tool def`, a Tool, a module that `extend Conductor::Agents::Tools`, or another + # Agent (wrapped as an agent tool) + # @param credentials [Array, nil] secret names when the scanner cannot see them + # @return [self] + def add_tool(tool, credentials: nil) + resolve_tool_defs(tool).each do |tool_def| + td = credentials ? tool_def.dup.tap { |d| d.credentials = tool_def.credentials.dup } : tool_def + td.add_credentials(*credentials) if credentials + raise ConfigurationError, "duplicate tool name #{td.name.inspect} on agent #{@name}" if @tools.any? { |t| t.name == td.name } + + @tools << td + end + self + end + + def add_tools(*tools) + tools.flatten.each { |t| add_tool(t) } + self + end + + # @return [Tool, nil] + def tool(name) + @tools.find { |t| t.name == name.to_s } + end + + # ── Team ────────────────────────────────────────────────────────── + + # Add a member agent (same as agents: in the constructor) + def add_agent(agent) + raise ConfigurationError, "add_agent expects an Agent, got #{agent.class}" unless agent.is_a?(Agent) + raise ConfigurationError, "duplicate sub-agent name #{agent.name.inspect} under #{@name}" if @agents.any? { |a| a.name == agent.name } + + @agents << agent + self + end + + def add_agents(*agents) + agents.flatten.each { |a| add_agent(a) } + self + end + + # Hand off to +agent+ when this agent's output mentions +on+ (String), or when the + # block/proc given as +on+ returns true. + def hands_off_to(agent, on:) + handoff = if on.respond_to?(:call) + Handoff::OnCondition.new(target: agent, condition: on) + else + Handoff::OnTextMention.new(target: agent, text: on) + end + @handoffs << handoff + self + end + + def add_handoff(handoff) + @handoffs << handoff + self + end + + # Sequential pipeline: a >> b >> c + def >>(other) + raise ConfigurationError, ">> expects an Agent, got #{other.class}" unless other.is_a?(Agent) + + left = sequential_pipeline? ? @agents : [self] + right = other.sequential_pipeline? ? other.agents : [other] + members = left + right + Agent.new(name: members.map(&:name).join('_'), model: @model || other.model, + agents: members, strategy: Strategy::SEQUENTIAL) + end + + def sequential_pipeline? + @strategy == Strategy::SEQUENTIAL && !@agents.empty? + end + + # ── Guardrails / termination sugar ──────────────────────────────── + + # Scrub these words from the output before anyone sees it + def redact(words, name: "#{@name}_redact") + patterns = Array(words).map { |w| w.is_a?(Regexp) ? w : Regexp.escape(w.to_s) } + @guardrails << RegexGuardrail.new(patterns, mode: :block, position: :output, on_fail: :fix, name: name) + self + end + + def add_guardrail(guardrail) + @guardrails << guardrail + self + end + + # Stop when the output contains +text+ + def stop_when(text, case_sensitive: false) + add_termination(Termination::TextMention.new(text, case_sensitive: case_sensitive)) + end + + # Stop after +messages+ messages + def stop_after(messages:) + add_termination(Termination::MaxMessage.new(messages)) + end + + def add_termination(condition) + @termination = @termination ? (@termination | condition) : condition + self + end + + # ── Callbacks ───────────────────────────────────────────────────── + + def add_callback(handler) + @callbacks << handler + self + end + + # Register a block for a callback position (before_model, after_model, ...) + def callback(position, &block) + pos = position.to_s + raise ConfigurationError, "unknown callback position #{position.inspect}" unless CallbackHandler::POSITIONS.include?(pos) + + @callback_procs[pos] << block + self + end + + # Callables for +position+, or nil when nothing is registered + def callback_chain(position, logger: nil) + CallbackHandler.chain(position, @callbacks, @callback_procs[position.to_s], logger: logger) + end + + # Positions with at least one handler or proc + def callback_positions + CallbackHandler::POSITIONS.reject { |p| callback_chain(p).nil? } + end + + # ── Approval ────────────────────────────────────────────────────── + + # Decide approval-required tool calls: the block receives an ApprovalRequest + def on_approval(&block) + @approval_handler = block + self + end + + # ── Credentials ─────────────────────────────────────────────────── + + def add_credentials(*names) + @credentials = (@credentials + names.flatten.map(&:to_s)).uniq + self + end + + # ── Execution (delegates to the default runtime) ────────────────── + + # Run and block until the answer is ready + # @return [String] + def call_sync(prompt, session_id: nil, **options) + Conductor::Agents.runtime.call_sync(self, prompt, session_id: session_id, **options) + end + + # Run in the background; returns an Execution. The block (if given) receives the answer. + def call_async(prompt, session_id: nil, **options, &on_done) + Conductor::Agents.runtime.call_async(self, prompt, session_id: session_id, **options, &on_done) + end + + # ── Introspection ───────────────────────────────────────────────── + + # Every agent in the tree (self first), including router, planner-style children and agent tools + def all_agents + list = [self] + @agents.each { |a| list.concat(a.all_agents) } + [@router, @planner, @fallback].each { |a| list.concat(a.all_agents) if a.is_a?(Agent) } + @tools.each do |t| + child = t.config['agent'] if t.tool_type == ToolType::AGENT_TOOL + list.concat(child.all_agents) if child.is_a?(Agent) + end + list.uniq + end + + # True when this agent or anything under it is stateful + def stateful_tree? + all_agents.any? { |a| a.stateful || a.tools.any?(&:stateful) } + end + + def to_s + "#" + end + alias inspect to_s + + private + + def resolve_tool_defs(tool) + case tool + when Tool then [tool] + when Symbol, String + [Tools.lookup(tool) || raise(ConfigurationError, "no tool named #{tool.inspect}; define it with `tool def #{tool}(...)` first")] + when Agent then [Tool.agent(tool)] + when Module + raise ConfigurationError, "#{tool} has no tools; use `extend Conductor::Agents::Tools` and `tool def ...`" unless tool.respond_to?(:tool_defs) + + tool.tool_defs + else + raise ConfigurationError, "cannot use #{tool.inspect} as a tool" + end + end + end + end +end diff --git a/lib/conductor/agents/callback_handler.rb b/lib/conductor/agents/callback_handler.rb new file mode 100644 index 0000000..29a28f9 --- /dev/null +++ b/lib/conductor/agents/callback_handler.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Conductor + module Agents + # Lifecycle hooks. Subclass and override any method; each runs as a worker task + # named _ that the server schedules at that point. + # + # class Timing < Conductor::Agents::CallbackHandler + # def on_model_start(messages: nil, **) = (@t0 = Time.now; nil) + # def on_model_end(llm_result: nil, **) = (puts Time.now - @t0; nil) + # end + # + # Return nil to continue to the next handler, or a non-empty Hash to short-circuit and + # hand that Hash to the server as an override. + class CallbackHandler + POSITION_TO_METHOD = { + 'before_agent' => :on_agent_start, + 'after_agent' => :on_agent_end, + 'before_model' => :on_model_start, + 'after_model' => :on_model_end, + 'before_tool' => :on_tool_start, + 'after_tool' => :on_tool_end + }.freeze + + POSITIONS = POSITION_TO_METHOD.keys.freeze + + def on_agent_start(**_kwargs); end + def on_agent_end(**_kwargs); end + def on_model_start(**_kwargs); end + def on_model_end(**_kwargs); end + def on_tool_start(**_kwargs); end + def on_tool_end(**_kwargs); end + + # True when this handler overrides the hook for +position+ + def handles?(position) + method_name = POSITION_TO_METHOD.fetch(position.to_s) + self.class.instance_method(method_name).owner != CallbackHandler + end + + class << self + # Build one callable for +position+ from a list of handlers (and optional procs), + # or nil when nothing is registered. First non-empty Hash wins; errors are logged. + # @return [Proc, nil] + def chain(position, handlers, procs = [], logger: nil) + position = position.to_s + method_name = POSITION_TO_METHOD.fetch(position) + active = Array(handlers).select { |h| h.handles?(position) } + callables = Array(procs) + active.map { |h| h.method(method_name) } + return nil if callables.empty? + + lambda do |**kwargs| + callables.each do |callable| + result = callable.call(**kwargs) + return result if result.is_a?(Hash) && !result.empty? + rescue StandardError => e + logger&.error("callback #{position} failed: #{e.class}: #{e.message}") + end + {} + end + end + end + end + end +end diff --git a/lib/conductor/agents/config_serializer.rb b/lib/conductor/agents/config_serializer.rb new file mode 100644 index 0000000..52fb455 --- /dev/null +++ b/lib/conductor/agents/config_serializer.rb @@ -0,0 +1,246 @@ +# frozen_string_literal: true + +require_relative 'agent' + +module Conductor + module Agents + # Serializes an Agent tree into the agentConfig JSON the server compiles. Same shape + # as the Python SDK's AgentConfigSerializer: camelCase keys, nils dropped, strategy only + # on agents with sub-agents, agent credentials at the top level and tool credentials + # under config.credentials. + # + # Two Ruby-specific rules: + # - a team parent with no model inherits the first member's model (the server requires + # a model on every agent config); + # - members that declared hands_off_to make a team with no explicit strategy a swarm, + # and their handoffs are hoisted to the team, which is where the server reads them. + class ConfigSerializer + def self.serialize(agent) + new.serialize(agent) + end + + # @param agent [Agent] + # @return [Hash] agentConfig + def serialize(agent) + serialize_agent(agent) + end + + private + + def serialize_agent(agent) + has_sub_agents = !agent.agents.empty? + strategy, handoffs = effective_strategy_and_handoffs(agent) + + config = { + 'name' => agent.name, + 'model' => effective_model(agent), + 'baseUrl' => agent.base_url, + 'strategy' => has_sub_agents || agent.planner || agent.fallback ? strategy : nil, + 'maxTurns' => agent.max_turns, + 'timeoutSeconds' => agent.timeout_seconds, + 'external' => agent.external, + 'description' => agent.description, + 'instructions' => serialize_instructions(agent.instructions) + } + config['tools'] = agent.tools.map { |t| serialize_tool(t, agent_stateful: agent.stateful) } unless agent.tools.empty? + config['agents'] = agent.agents.map { |a| serialize_agent(a) } if has_sub_agents + config.merge!(serialize_plan(agent)) + config['router'] = serialize_router(agent) unless agent.router.nil? + config['outputType'] = serialize_output_type(agent.output_type) unless agent.output_type.nil? + config['guardrails'] = agent.guardrails.map { |g| serialize_guardrail(g) } unless agent.guardrails.empty? + config['memory'] = serialize_memory(agent.memory) if agent.memory && !agent.memory.empty? + config.merge!(serialize_scalars(agent)) + config['termination'] = serialize_termination(agent.termination) unless agent.termination.nil? + config['handoffs'] = handoffs.map { |h| serialize_handoff(h, agent.name) } unless handoffs.empty? + config.merge!(serialize_extras(agent)) + config.compact + end + + def serialize_scalars(agent) + { + 'maxTokens' => agent.max_tokens, + 'temperature' => agent.temperature + } + end + + def serialize_plan(agent) + config = {} + config['planner'] = serialize_agent(agent.planner) if agent.planner + config['fallback'] = serialize_agent(agent.fallback) if agent.fallback + config['fallbackMaxTurns'] = agent.fallback_max_turns unless agent.fallback_max_turns.nil? + config['plannerContext'] = agent.planner_context unless agent.planner_context.empty? + config + end + + def serialize_extras(agent) + extras = {} + extras['metadata'] = agent.metadata if agent.metadata && !agent.metadata.empty? + callbacks = agent.callback_positions.map { |p| { 'position' => p, 'taskName' => "#{agent.name}_#{p}" } } + extras['callbacks'] = callbacks unless callbacks.empty? + extras['prefillTools'] = agent.prefill_tools.map(&:to_h) unless agent.prefill_tools.empty? + extras['credentials'] = agent.credentials unless agent.credentials.empty? + extras + end + + def effective_model(agent) + return agent.model if agent.model && !agent.model.to_s.empty? + return nil if agent.external + + inherited = (agent.agents + [agent.planner, agent.fallback].compact).map { |a| effective_model(a) }.compact.first + return inherited if inherited + + raise ConfigurationError, + "agent #{agent.name.inspect} has no model: pass model: 'provider/model' (the server requires one)" + end + + # Swarm hoisting: members with hands_off_to make the parent a swarm unless the user chose a strategy + def effective_strategy_and_handoffs(agent) + member_handoffs = agent.agents.flat_map(&:handoffs) + return [agent.strategy, agent.handoffs] if member_handoffs.empty? + + strategy = agent.strategy_set? ? agent.strategy : Strategy::SWARM + return [strategy, agent.handoffs] unless strategy == Strategy::SWARM + + hoisted = (agent.handoffs + member_handoffs).uniq { |h| [h.class, h.target, h.respond_to?(:text) ? h.text : nil] } + [strategy, hoisted] + end + + def serialize_instructions(instructions) + case instructions + when PromptTemplate then instructions.to_h + when Proc, Method then instructions.call + when nil then nil + else + s = instructions.to_s + s.empty? ? nil : s + end + end + + def serialize_tool(tool_def, agent_stateful: false) + result = { + 'name' => tool_def.name, + 'description' => tool_def.description, + 'inputSchema' => tool_def.input_schema, + 'toolType' => tool_def.tool_type + } + result['outputSchema'] = tool_def.output_schema unless tool_def.output_schema.nil? || tool_def.output_schema.empty? + result['approvalRequired'] = true if tool_def.approval_required + result['stateful'] = true if agent_stateful || tool_def.stateful + result['timeoutSeconds'] = tool_def.timeout_seconds unless tool_def.timeout_seconds.nil? + result['maxCalls'] = tool_def.max_calls unless tool_def.max_calls.nil? + + unless tool_def.config.empty? + config = tool_def.config.transform_keys(&:to_s) + config['agentConfig'] = serialize_agent(config.delete('agent')) if tool_def.tool_type == ToolType::AGENT_TOOL && config.key?('agent') + result['config'] = config + end + + result['guardrails'] = tool_def.guardrails.map { |g| serialize_guardrail(g) } unless tool_def.guardrails.empty? + + unless tool_def.credentials.empty? + result['config'] ||= {} + result['config']['credentials'] = tool_def.credentials + end + + result + end + + def serialize_guardrail(guardrail) + result = { + 'name' => guardrail.name, + 'position' => guardrail.position, + 'onFail' => guardrail.on_fail, + 'maxRetries' => guardrail.max_retries, + 'guardrailType' => guardrail.guardrail_type + } + case guardrail + when RegexGuardrail + result['patterns'] = guardrail.pattern_strings + result['mode'] = guardrail.mode + result['message'] = guardrail.message if guardrail.message + when LlmGuardrail + result['model'] = guardrail.model + result['policy'] = guardrail.policy + result['maxTokens'] = guardrail.max_tokens if guardrail.max_tokens + else + result['taskName'] = guardrail.name + end + result + end + + def serialize_termination(condition) + case condition + when Termination::TextMention + { 'type' => 'text_mention', 'text' => condition.text, 'caseSensitive' => condition.case_sensitive } + when Termination::StopMessage + { 'type' => 'stop_message', 'stopMessage' => condition.stop_message } + when Termination::MaxMessage + { 'type' => 'max_message', 'maxMessages' => condition.max_messages } + when Termination::TokenUsage + h = { 'type' => 'token_usage' } + h['maxTotalTokens'] = condition.max_total_tokens unless condition.max_total_tokens.nil? + h['maxPromptTokens'] = condition.max_prompt_tokens unless condition.max_prompt_tokens.nil? + h['maxCompletionTokens'] = condition.max_completion_tokens unless condition.max_completion_tokens.nil? + h + when Termination::And + { 'type' => 'and', 'conditions' => condition.conditions.map { |c| serialize_termination(c) } } + when Termination::Or + { 'type' => 'or', 'conditions' => condition.conditions.map { |c| serialize_termination(c) } } + else + { 'type' => 'unknown' } + end + end + + def serialize_handoff(handoff, agent_name) + result = { 'target' => handoff.target } + case handoff + when Handoff::OnToolResult + result['type'] = 'on_tool_result' + result['toolName'] = handoff.tool_name + result['resultContains'] = handoff.result_contains if handoff.result_contains + when Handoff::OnTextMention + result['type'] = 'on_text_mention' + result['text'] = handoff.text + when Handoff::OnCondition + result['type'] = 'on_condition' + result['taskName'] = "#{agent_name}_handoff_#{handoff.target}" + else + result['type'] = 'unknown' + end + result + end + + def serialize_router(agent) + router = agent.router + return serialize_agent(router) if router.is_a?(Agent) + return { 'taskName' => "#{agent.name}_router_fn" } if router.respond_to?(:call) + + nil + end + + # output_type: a JSON schema Hash, optionally wrapped as { schema:, class_name: } + def serialize_output_type(output_type) + schema = output_type.respond_to?(:to_json_schema) ? output_type.to_json_schema : output_type + schema = schema.transform_keys(&:to_s) if schema.is_a?(Hash) + if schema.is_a?(Hash) && (schema.key?('schema') || schema.key?('className') || schema.key?('class_name')) + result = {} + result['schema'] = schema['schema'] if schema['schema'] + class_name = schema['className'] || schema['class_name'] + result['className'] = class_name if class_name + return result + end + + result = { 'schema' => schema } + result['className'] = schema['title'] if schema.is_a?(Hash) && schema['title'] + result + end + + def serialize_memory(memory) + result = {} + result['messages'] = memory.messages unless memory.messages.empty? + result['maxMessages'] = memory.max_messages if memory.max_messages + result + end + end + end +end diff --git a/lib/conductor/agents/errors.rb b/lib/conductor/agents/errors.rb new file mode 100644 index 0000000..8ce18e9 --- /dev/null +++ b/lib/conductor/agents/errors.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative '../exceptions' + +module Conductor + module Agents + # Base class for agent definition and runtime errors + class Error < ConductorError; end + + # Invalid agent/tool definition (bad name, missing model, positional tool args, ...) + class ConfigurationError < Error; end + + # secret('X') was called but X is neither on the task's runtimeMetadata nor in ENV + class CredentialNotFoundError < Error; end + + # A tool returned something that cannot be serialized to JSON + class ToolSerializationError < Error; end + + # The SSE stream could not be opened (non-200, connection failure, heartbeat-only) + class SseUnavailableError < Error; end + + # Server-side agent API errors are the transport-level classes + AgentApiError = Conductor::AgentApiError + AgentNotFoundError = Conductor::AgentNotFoundError + end +end diff --git a/lib/conductor/agents/guardrail.rb b/lib/conductor/agents/guardrail.rb new file mode 100644 index 0000000..633cde1 --- /dev/null +++ b/lib/conductor/agents/guardrail.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require_relative 'errors' + +module Conductor + module Agents + # Result of a guardrail check + GuardrailResult = Struct.new(:passed, :message, :fixed_output, keyword_init: true) do + def initialize(passed:, message: '', fixed_output: nil) + super + end + + def passed? + passed ? true : false + end + end + + # Validation applied to an agent's input or output. + # + # Guardrail.new(name: 'no_pii', position: :output, on_fail: :retry) do |content| + # content =~ SSN ? GuardrailResult.new(passed: false, message: 'Redact it') : GuardrailResult.new(passed: true) + # end + # + # A guardrail with a block runs as a worker in this process (guardrailType "custom"); + # one with only a name references a worker running elsewhere ("external"). + class Guardrail + POSITIONS = %w[input output].freeze + ON_FAIL = %w[retry raise fix human].freeze + + attr_reader :name, :position, :on_fail, :max_retries, :func + + # @param name [String, nil] required when no block is given + # @param position [Symbol, String] :input or :output + # @param on_fail [Symbol, String] :retry, :raise, :fix or :human + def initialize(name: nil, position: :output, on_fail: :raise, max_retries: 3, func: nil, &block) + @position = position.to_s + @on_fail = on_fail.to_s + raise ConfigurationError, "invalid position #{position.inspect}; use :input or :output" unless POSITIONS.include?(@position) + raise ConfigurationError, "invalid on_fail #{on_fail.inspect}; use one of #{ON_FAIL.join(', ')}" unless ON_FAIL.include?(@on_fail) + raise ConfigurationError, 'on_fail: :human is only valid for position: :output' if @on_fail == 'human' && @position == 'input' + raise ConfigurationError, 'max_retries must be >= 1' if max_retries.to_i < 1 + + @func = func || block + raise ConfigurationError, 'a guardrail needs a name or a block' if @func.nil? && name.nil? + + @name = (name || 'guardrail').to_s + @max_retries = max_retries.to_i + end + + # True when the check runs somewhere else (no local implementation) + def external? + @func.nil? + end + + # @param content [String] + # @return [GuardrailResult] + def check(content) + raise Error, "cannot check external guardrail #{@name.inspect} locally" if external? + + result = @func.call(content) + return result if result.is_a?(GuardrailResult) + return GuardrailResult.new(passed: result) if [true, false].include?(result) + + raise Error, "guardrail #{@name.inspect} must return a GuardrailResult or true/false, got #{result.class}" + end + + # Wire discriminator (see ConfigSerializer) + def guardrail_type + external? ? 'external' : 'custom' + end + + def to_s + "#<#{self.class.name.split('::').last} #{@name} position=#{@position} on_fail=#{@on_fail}>" + end + alias inspect to_s + end + + # Reject content that matches (mode :block) or fails to match (mode :allow) regex patterns. + class RegexGuardrail < Guardrail + MODES = %w[block allow].freeze + + attr_reader :pattern_strings, :mode, :message + + # @param patterns [String, Regexp, Array] + def initialize(patterns, mode: :block, position: :output, on_fail: :raise, name: 'regex_guardrail', + message: nil, max_retries: 3) + @mode = mode.to_s + raise ConfigurationError, "invalid mode #{mode.inspect}; use :block or :allow" unless MODES.include?(@mode) + + @pattern_strings = Array(patterns).map { |p| p.is_a?(Regexp) ? p.source : p.to_s } + @patterns = @pattern_strings.map { |p| Regexp.new(p) } + @message = message + super(name: name, position: position, on_fail: on_fail, max_retries: max_retries, func: method(:evaluate)) + end + + def guardrail_type + 'regex' + end + + private + + def evaluate(content) + text = content.to_s + matched = @patterns.any? { |p| p.match?(text) } + if @mode == 'block' && matched + GuardrailResult.new(passed: false, message: @message || 'Content matched a blocked pattern.') + elsif @mode == 'allow' && !matched + GuardrailResult.new(passed: false, message: @message || 'Content did not match any allowed pattern.') + else + GuardrailResult.new(passed: true) + end + end + end + + # Ask an LLM (on the server) whether content complies with a policy. + class LlmGuardrail < Guardrail + attr_reader :model, :policy, :max_tokens + + # @param model [String] "provider/model" + def initialize(model, policy, position: :output, on_fail: :raise, name: 'llm_guardrail', max_retries: 3, + max_tokens: nil) + raise ConfigurationError, 'LlmGuardrail needs a model in "provider/model" form' unless model.to_s.include?('/') + + @model = model + @policy = policy + @max_tokens = max_tokens + super(name: name, position: position, on_fail: on_fail, max_retries: max_retries, func: method(:evaluate)) + end + + def guardrail_type + 'llm' + end + + private + + # The server compiles this guardrail into an LLM task; there is no local evaluation. + def evaluate(_content) + GuardrailResult.new(passed: false, message: 'LlmGuardrail is evaluated by the Conductor server') + end + end + end +end diff --git a/lib/conductor/agents/handoff.rb b/lib/conductor/agents/handoff.rb new file mode 100644 index 0000000..ed387aa --- /dev/null +++ b/lib/conductor/agents/handoff.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require_relative 'errors' + +module Conductor + module Agents + # Rules that transfer control between agents in a team (swarm orchestration). + # + # Handoff::OnTextMention.new(target: 'filer', text: 'ACTIONABLE') + # Handoff::OnToolResult.new(target: 'refund', tool_name: 'check_order') + # Handoff::OnCondition.new(target: 'summarizer') { |ctx| ctx['iteration'].to_i > 5 } + module Handoff + # Base class. +target+ is the receiving agent's name (an Agent is accepted too). + class Condition + attr_reader :target + + def initialize(target:) + @target = target.respond_to?(:name) ? target.name.to_s : target.to_s + raise ConfigurationError, 'handoff target is required' if @target.empty? + end + + # @param _context [Hash] result, tool_name, tool_result, messages, iteration + def should_handoff(_context) + false + end + + def to_s + "#<#{self.class.name.split('::').last} -> #{@target}>" + end + alias inspect to_s + + protected + + def ctx(context, key) + return nil unless context.respond_to?(:key?) + + context.key?(key.to_s) ? context[key.to_s] : context[key.to_sym] + end + end + + # After a named tool ran (optionally only when its result contains a substring) + class OnToolResult < Condition + attr_reader :tool_name, :result_contains + + def initialize(target:, tool_name:, result_contains: nil) + @tool_name = tool_name.to_s + @result_contains = result_contains + super(target: target) + end + + def should_handoff(context) + return false unless ctx(context, :tool_name).to_s == @tool_name + return true if @result_contains.nil? + + ctx(context, :tool_result).to_s.include?(@result_contains.to_s) + end + end + + # When the output mentions +text+ (case-insensitive) + class OnTextMention < Condition + attr_reader :text + + def initialize(target:, text:) + @text = text.to_s + raise ConfigurationError, 'text is required' if @text.empty? + + super(target: target) + end + + def should_handoff(context) + ctx(context, :result).to_s.downcase.include?(@text.downcase) + end + end + + # When a block returns true; runs as the _handoff_ worker + class OnCondition < Condition + attr_reader :condition + + def initialize(target:, condition: nil, &block) + @condition = condition || block + raise ConfigurationError, 'OnCondition needs a block' if @condition.nil? + + super(target: target) + end + + def should_handoff(context) + @condition.call(context) ? true : false + rescue StandardError + false + end + end + end + end +end diff --git a/lib/conductor/agents/memory.rb b/lib/conductor/agents/memory.rb new file mode 100644 index 0000000..35f360b --- /dev/null +++ b/lib/conductor/agents/memory.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Conductor + module Agents + # Conversation history seeded into the agent (memory: on Agent). Messages are + # prepended to the LLM conversation by the server; max_messages trims the oldest + # non-system messages first. + class ConversationMemory + attr_reader :messages, :max_messages + + def initialize(messages: [], max_messages: nil) + @messages = messages.map { |m| m.transform_keys(&:to_s) } + @max_messages = max_messages + trim + end + + def add_user_message(content) + push('role' => 'user', 'message' => content.to_s) + end + + def add_assistant_message(content) + push('role' => 'assistant', 'message' => content.to_s) + end + + def add_system_message(content) + push('role' => 'system', 'message' => content.to_s) + end + + def add_tool_call(tool_name, arguments, task_reference_name: nil) + ref = task_reference_name || "#{tool_name}_ref" + push('role' => 'tool_call', 'message' => '', + 'tool_calls' => [{ 'name' => tool_name.to_s, 'taskReferenceName' => ref, 'input' => arguments }]) + end + + def add_tool_result(tool_name, result, task_reference_name: nil) + ref = task_reference_name || "#{tool_name}_ref" + push('role' => 'tool', 'message' => result.to_s, 'toolCallId' => ref, 'taskReferenceName' => ref) + end + + # Deep copy of the messages + def to_chat_messages + Marshal.load(Marshal.dump(@messages)) + end + + def clear + @messages.clear + end + + def empty? + @messages.empty? + end + + private + + def push(message) + @messages << message + trim + end + + def trim + return unless @max_messages && @messages.size > @max_messages + + system_msgs, others = @messages.partition { |m| m['role'] == 'system' } + if system_msgs.size >= @max_messages + @messages = system_msgs.last(@max_messages) + return + end + + keep = @max_messages - system_msgs.size + dropped = others.first(others.size - keep) + @messages = @messages.reject { |m| dropped.any? { |d| d.equal?(m) } } + end + end + end +end diff --git a/lib/conductor/agents/plans.rb b/lib/conductor/agents/plans.rb new file mode 100644 index 0000000..3802173 --- /dev/null +++ b/lib/conductor/agents/plans.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require_relative 'agent' + +module Conductor + module Agents + # Build the server-side planner, optional recovery agent, and coordinator. + module Plans + def plan_execute(name:, tools:, model:, planner_instructions: '', fallback_instructions: nil, + fallback_max_turns: nil, planner_context: []) + planner = Agent.new(name: "#{name}_planner", model: model, instructions: planner_instructions) + unless fallback_instructions.nil? || fallback_instructions.empty? + fallback = Agent.new(name: "#{name}_fallback", model: model, + instructions: fallback_instructions, tools: tools) + end + Agent.new(name: name, model: model, strategy: :plan_execute, tools: tools, + planner: planner, fallback: fallback, fallback_max_turns: fallback_max_turns, + planner_context: planner_context) + end + end + end +end diff --git a/lib/conductor/agents/prompt_template.rb b/lib/conductor/agents/prompt_template.rb new file mode 100644 index 0000000..56e73e7 --- /dev/null +++ b/lib/conductor/agents/prompt_template.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Conductor + module Agents + # Reference to a prompt template stored on the server, used as Agent#instructions. + class PromptTemplate + attr_reader :name, :variables, :version + + def initialize(name:, variables: {}, version: nil) + @name = name.to_s + @variables = variables || {} + @version = version + end + + def to_h + h = { 'type' => 'prompt_template', 'name' => @name } + h['variables'] = @variables unless @variables.empty? + h['version'] = @version unless @version.nil? + h + end + end + end +end diff --git a/lib/conductor/agents/runtime/agent_config.rb b/lib/conductor/agents/runtime/agent_config.rb new file mode 100644 index 0000000..6784529 --- /dev/null +++ b/lib/conductor/agents/runtime/agent_config.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Conductor + module Agents + # Runtime knobs, read from CONDUCTOR_AGENT_* environment variables (same names and + # defaults as the Python SDK's AgentConfig). + class AgentConfig + TRUE_VALUES = %w[true 1 yes on].freeze + FALSE_VALUES = %w[false 0 no off].freeze + + attr_accessor :worker_poll_interval_ms, :worker_thread_count, :auto_register_integrations, + :streaming_enabled, :status_poll_interval_seconds, :system_worker_thread_count + + def initialize(worker_poll_interval_ms: 100, worker_thread_count: 1, auto_register_integrations: false, + streaming_enabled: true, status_poll_interval_seconds: 0.5, system_worker_thread_count: 10) + @worker_poll_interval_ms = worker_poll_interval_ms + @worker_thread_count = worker_thread_count + @auto_register_integrations = auto_register_integrations + @streaming_enabled = streaming_enabled + @status_poll_interval_seconds = status_poll_interval_seconds + @system_worker_thread_count = system_worker_thread_count + end + + # @param env [Hash] defaults to ENV + def self.from_env(env = ENV) + new( + worker_poll_interval_ms: int(env, 'CONDUCTOR_AGENT_WORKER_POLL_INTERVAL', 100), + worker_thread_count: int(env, 'CONDUCTOR_AGENT_WORKER_THREADS', 1), + auto_register_integrations: bool(env, 'CONDUCTOR_AGENT_INTEGRATIONS_AUTO_REGISTER', false), + streaming_enabled: bool(env, 'CONDUCTOR_AGENT_STREAMING_ENABLED', true) + ) + end + + def self.int(env, key, default) + raw = env[key].to_s.strip + raw.empty? ? default : Integer(raw, 10) + rescue ArgumentError + default + end + + def self.bool(env, key, default) + raw = env[key].to_s.strip.downcase + return default if raw.empty? + return true if TRUE_VALUES.include?(raw) + return false if FALSE_VALUES.include?(raw) + + default + end + private_class_method :int, :bool + end + end +end diff --git a/lib/conductor/agents/runtime/agent_runtime.rb b/lib/conductor/agents/runtime/agent_runtime.rb new file mode 100644 index 0000000..044a336 --- /dev/null +++ b/lib/conductor/agents/runtime/agent_runtime.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true + +require 'securerandom' +require 'logger' +require 'set' +require_relative '../errors' +require_relative '../config_serializer' +require_relative 'agent_config' +require_relative 'execution' +require_relative 'approval_request' +require_relative 'sse_client' +require_relative 'status_poller' +require_relative 'tool_registry' +require_relative '../../client/agent_client' +require_relative '../../worker/task_handler' + +module Conductor + module Agents + # Runs agents against a Conductor server: serializes the agentConfig, starts the + # execution, registers the workers the server asks for, and streams the result. + # + # runtime = Conductor::Agents::AgentRuntime.new(configuration: Conductor::Configuration.new) + # runtime.call_sync(agent, 'Weather in Lisbon?') + # + # Conductor::Agents.runtime holds a default instance built from the environment; + # Agent#call_sync / #call_async use it. + class AgentRuntime + attr_reader :configuration, :agent_config, :client, :api_client, :logger + + def initialize(configuration: nil, agent_config: nil, logger: nil, api_client: nil, agent_client: nil) + @configuration = configuration || Configuration.new + @agent_config = agent_config || AgentConfig.from_env + @logger = logger || Logger.new($stdout, level: Logger::INFO, progname: 'conductor-agents') + @api_client = api_client || Http::ApiClient.new(configuration: @configuration) + @client = agent_client || Client::AgentClient.new(@api_client) + @registry = ToolRegistry.new(@agent_config, logger: @logger) + @handlers = [] + @running_workers = Set.new + @stream_threads = [] + @mutex = Mutex.new + end + + # Run and wait for the answer + # @return [String] + def call_sync(agent, prompt, session_id: nil, timeout: nil, **options) + call_async(agent, prompt, session_id: session_id, **options).result(timeout: timeout) + end + + # Start the agent and return immediately with an Execution + # @param session_id [String, nil] conversation id to continue + # @param media [Array, nil], context [Hash, nil], idempotency_key [String, nil], timeout_seconds [Integer, nil] + # @yield [answer, execution] runs on the stream thread when the execution finishes + # @return [Execution] + def call_async(agent, prompt, session_id: nil, media: nil, context: nil, idempotency_key: nil, + timeout_seconds: nil, on_event: nil, &on_done) + payload = start_payload(agent, prompt, session_id: session_id, media: media, context: context, + idempotency_key: idempotency_key, timeout_seconds: timeout_seconds) + response = @client.start_agent(payload) + execution_id = response['executionId'] || raise(Error, "server returned no executionId: #{response.inspect}") + + execution = Execution.new(execution_id, client: @client, agent_name: response['agentName'] || agent.name, runtime: self) + start_workers(agent, response['requiredWorkers'], domain: payload['runId']) + attach(execution, agent: agent, on_event: on_event, &on_done) + execution + end + + # Register agents on the server without running them + # @return [Array] deployed agent names + def deploy(*agents) + agents.flatten.map do |agent| + response = @client.deploy_agent('agentConfig' => ConfigSerializer.serialize(agent)) + response['agentName'] || agent.name + end + end + + # Compile without registering: { "workflowDef", "requiredWorkers" } + def compile(agent) + @client.compile_agent('agentConfig' => ConfigSerializer.serialize(agent)) + end + + # Deploy, start workers for every tool, and (by default) block until INT/TERM + def serve(*agents, blocking: true) + agents = agents.flatten + agents.each do |agent| + response = @client.deploy_agent('agentConfig' => ConfigSerializer.serialize(agent)) + start_workers(agent, response['requiredWorkers'], domain: nil) + end + return self unless blocking + + wait_for_signal + shutdown + self + end + + # Follow an execution on a background thread (used by call_async and Execution#result) + def attach(execution, agent: nil, on_event: nil, &on_done) + execution.attached! + thread = Thread.new do + Thread.current.name = "conductor-agent-stream-#{execution.execution_id}" + follow(execution, agent, on_event: on_event, &on_done) + end + @mutex.synchronize { @stream_threads << thread } + thread + end + + # Stop workers and stream threads + def shutdown(timeout: 5) + handlers, threads = @mutex.synchronize do + h = @handlers.dup + t = @stream_threads.dup + @handlers.clear + @stream_threads.clear + @running_workers.clear + [h, t] + end + handlers.each { |h| h.stop(timeout: timeout) } + threads.each do |t| + t.join(timeout) + t.kill if t.alive? + end + self + end + + # Names of the workers currently polling + def running_workers + @mutex.synchronize { @running_workers.map(&:first) } + end + + # Build the AgentStartRequest body + def start_payload(agent, prompt, session_id: nil, media: nil, context: nil, idempotency_key: nil, timeout_seconds: nil) + payload = { + 'agentConfig' => ConfigSerializer.serialize(agent), + 'prompt' => prompt.to_s, + 'sessionId' => session_id.to_s, + 'media' => Array(media) + } + payload['context'] = context if context && !context.empty? + payload['idempotencyKey'] = idempotency_key if idempotency_key + payload['timeoutSeconds'] = timeout_seconds if timeout_seconds + payload['runId'] = SecureRandom.hex(16) if agent.stateful_tree? + payload + end + + private + + # Start workers for the tools and system tasks the server requires (skipping ones already polling) + def start_workers(agent, required_workers, domain:) + workers = @registry.workers_for(agent, required_workers: required_workers, domain: domain) + fresh = @mutex.synchronize do + workers.reject { |w| @running_workers.include?([w.task_definition_name, w.domain]) } + .each { |w| @running_workers << [w.task_definition_name, w.domain] } + end + return if fresh.empty? + + handler = Worker::TaskHandler.new(workers: fresh, configuration: @configuration, logger: @logger, + scan_for_annotated_workers: false, register_task_definitions: true) + handler.start + @mutex.synchronize { @handlers << handler } + @logger.info("agent workers started: #{fresh.map(&:task_definition_name).join(', ')}") + end + + def follow(execution, agent, on_event: nil, &on_done) + events = event_source(execution.execution_id) + events.each do |event| + handle_event(execution, agent, event) + run_callback(on_event, event) if on_event + break if execution.done? + end + execution.fail('stream ended before the execution finished') unless execution.done? + rescue StandardError => e + @logger.error("stream for #{execution.execution_id} failed: #{e.class}: #{e.message}") + execution.fail("#{e.class}: #{e.message}") unless execution.done? + ensure + run_callback(on_done, execution.answer, execution) if on_done + end + + def event_source(execution_id) + poller = StatusPoller.new(@client, interval: @agent_config.status_poll_interval_seconds, logger: @logger) + return poller.each_event(execution_id) unless @agent_config.streaming_enabled + + sse = SseClient.new(@api_client, logger: @logger) + Enumerator.new do |y| + sse.each_event(execution_id) { |ev| y << ev } + rescue SseUnavailableError => e + @logger.info("SSE unavailable (#{e.message}); polling status instead") + poller.each_event(execution_id) { |ev| y << ev } + end + end + + def handle_event(execution, agent, event) + data = event['data'] || {} + execution.record_event(event) + case event['event'].to_s + when 'message' + execution.append_text(data['content']) + when 'tool_call' + execution.add_tool_call(data['toolName'], strip_injected(data['args'])) + when 'tool_result' + execution.add_tool_result(data['toolName'], data['result']) + when 'waiting' + handle_waiting(execution, agent, data) + when 'done' + execution.token_usage = fetch_token_usage(execution.execution_id) + execution.finish(status: 'COMPLETED', output: data['output'] || {}) + when 'error' + execution.finish(status: data['status'] || 'FAILED', output: data['output'] || {}, + reason: data['content'] || 'execution failed') + end + end + + def handle_waiting(execution, agent, data) + pending = data['pendingTool'] || {} + request = ApprovalRequest.new(execution.execution_id, pending, client: @client, execution: execution) + execution.mark_waiting(request) + handler = agent&.approval_handler + return if handler.nil? || request.tool_calls.empty? + + run_callback(handler, request) + end + + def run_callback(callable, *args) + callable.call(*args) + rescue StandardError => e + @logger.error("callback raised #{e.class}: #{e.message}") + end + + def strip_injected(args) + return {} unless args.is_a?(Hash) + + args.reject { |k, _| Dispatch::INJECTED_KEYS.include?(k.to_s) } + end + + # Sum tokenUsage over the execution and its sub-agent executions + def fetch_token_usage(execution_id, visited = Set.new) + return TokenUsage.new if visited.include?(execution_id) || visited.size > 50 + + visited << execution_id + run = @client.get_execution(execution_id) + usage = run['tokenUsage'] || {} + total = TokenUsage.new(prompt_tokens: usage['promptTokens'].to_i, completion_tokens: usage['completionTokens'].to_i, + total_tokens: usage['totalTokens'].to_i) + Array(run['tasks']).each do |task| + sub = task['subWorkflowId'] + total += fetch_token_usage(sub, visited) if sub && !sub.to_s.empty? + end + total + rescue StandardError => e + @logger.debug("token usage unavailable for #{execution_id}: #{e.message}") + TokenUsage.new + end + + def wait_for_signal + queue = Queue.new + %w[INT TERM].each { |sig| trap(sig) { queue << sig } } + @logger.info('serving agents; press Ctrl-C to stop') + queue.pop + end + end + end +end diff --git a/lib/conductor/agents/runtime/approval_request.rb b/lib/conductor/agents/runtime/approval_request.rb new file mode 100644 index 0000000..f0f1801 --- /dev/null +++ b/lib/conductor/agents/runtime/approval_request.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require_relative '../errors' +require_relative 'tool_call' + +module Conductor + module Agents + # A tool call (or batch of them) waiting for a human decision. + # + # agent.on_approval do |request| + # request.amount < 100 ? request.approve : request.reject('Needs a manager') + # end + # + # The server pauses on one HUMAN task per turn, so a request may carry several tool + # calls; +tool_name+ and the argument accessors (request.amount) read the first one. + class ApprovalRequest + attr_reader :execution_id, :task_ref_name, :tool_calls, :response_schema, :raw + + # @param pending_tool [Hash] the SSE "waiting" event's pendingTool payload + def initialize(execution_id, pending_tool, client:, execution: nil) + @execution_id = execution_id + @client = client + @execution = execution + @raw = pending_tool || {} + @task_ref_name = @raw['taskRefName'] + @response_schema = @raw['response_schema'] + @tool_calls = extract_tool_calls(@raw) + @responded = false + end + + # First tool call's name + def tool_name + @tool_calls.first&.name + end + + # First tool call's arguments (String keys) + def arguments + @tool_calls.first&.arguments || {} + end + + def responded? + @responded + end + + # Let the tool run + def approve + complete_response { @client.approve(@execution_id) } + end + + # Skip the tool; the run ends COMPLETED with finish_reason :rejected + def reject(reason = '') + complete_response { @client.reject(@execution_id, reason) } + end + + # Free-text answer (human tools / feedback) + def send_message(message) + complete_response { @client.send_message(@execution_id, message) } + end + + # Submit fields requested by response_schema (approval plus reviewer feedback, + # or structured input for a human tool). + def respond(body) + complete_response { @client.respond(@execution_id, body) } + end + + # request.amount, request.order_id ... read the first tool call's arguments + def method_missing(name, *args, &block) + key = name.to_s + return arguments[key] if args.empty? && arguments.key?(key) + + super + end + + def respond_to_missing?(name, include_private = false) + arguments.key?(name.to_s) || super + end + + def to_s + calls = @tool_calls.map(&:to_s).join(', ') + "#" + end + alias inspect to_s + + private + + def complete_response + raise Error, 'approval request already answered' if @responded + + yield + @responded = true + @execution&.clear_waiting + self + end + + def extract_tool_calls(raw) + calls = raw['toolCalls'] || raw['tool_calls'] + if calls.is_a?(Array) && !calls.empty? + return calls.map do |c| + c = c.transform_keys(&:to_s) + ToolCall.new(name: c['name'], arguments: (c['args'] || c['arguments'] || c['parameters'] || {}).transform_keys(&:to_s)) + end + end + + name = raw['tool_name'] || raw['toolName'] + return [] if name.nil? + + [ToolCall.new(name: name, arguments: (raw['parameters'] || raw['args'] || {}).transform_keys(&:to_s))] + end + end + end +end diff --git a/lib/conductor/agents/runtime/dispatch.rb b/lib/conductor/agents/runtime/dispatch.rb new file mode 100644 index 0000000..488bc58 --- /dev/null +++ b/lib/conductor/agents/runtime/dispatch.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require 'json' +require_relative '../errors' +require_relative 'secrets' +require_relative '../../http/models/task_result' + +module Conductor + module Agents + # Executes one tool task: maps the task's inputData onto the tool method's keyword + # arguments, runs it, and shapes the result for the server. + # + # The server sends the LLM's arguments as top-level keys plus a few injected keys + # (method, _agent_state, _agent_tool_name, _allowed_commands) that are stripped here. + # Missing required arguments, missing credentials and unserializable results are + # terminal failures; anything raised by the tool itself is a retryable failure. + module Dispatch + INJECTED_KEYS = %w[method _agent_state _agent_tool_name _allowed_commands __conductor_agent_ctx__].freeze + WORKER_ID = 'agent-sdk' + TRUE_STRINGS = %w[true 1 yes].freeze + FALSE_STRINGS = %w[false 0 no].freeze + + # Raised when the LLM omitted a required argument + class MissingArgumentError < Error; end + + module_function + + # @param task [Http::Models::Task] + # @param tool_def [Tool] + # @return [Http::Models::TaskResult] + def run_tool_task(task, tool_def, logger: nil) + result = base_result(task) + input = (task.input_data || {}).transform_keys(&:to_s) + args = input.except(*INJECTED_KEYS) + + check_credentials!(tool_def) + kwargs = coerce_args(args, tool_def) + output = tool_def.func.call(**kwargs) + return output if output.is_a?(Http::Models::TaskResult) + + result.status = Http::Models::TaskResultStatus::COMPLETED + result.output_data = normalize_output(tool_def, output) + result + rescue MissingArgumentError, CredentialNotFoundError, ToolSerializationError => e + logger&.error("tool #{tool_def.name}: #{e.message}") + terminal_failure(result, e) + rescue StandardError => e + logger&.error("tool #{tool_def.name} raised #{e.class}: #{e.message}") + result.status = Http::Models::TaskResultStatus::FAILED + result.reason_for_incompletion = "#{e.class}: #{e.message}" + result + end + + # Map input keys to the tool's keyword arguments, coercing by the JSON schema + # @return [Hash] + def coerce_args(args, tool_def) + schema = tool_def.input_schema || {} + properties = schema['properties'] || {} + required = Array(schema['required']) + accepts_rest = tool_def.func.respond_to?(:parameters) && tool_def.func.parameters.any? { |kind, _| kind == :keyrest } + + missing = required.reject { |name| args.key?(name) } + raise MissingArgumentError, "tool #{tool_def.name}: missing required argument(s) #{missing.join(', ')}" unless missing.empty? + + args.each_with_object({}) do |(name, value), kwargs| + if properties.key?(name) + kwargs[name.to_sym] = coerce_value(value, properties[name]) + elsif accepts_rest || properties.empty? + kwargs[name.to_sym] = value + end + end + end + + # Coerce one value to its JSON schema type (LLMs often send numbers and JSON as strings) + def coerce_value(value, property) + return value unless property.is_a?(Hash) + + type = property['type'] + case type + when 'integer' + value.is_a?(String) ? (Integer(value, 10) rescue value) : value # rubocop:disable Style/RescueModifier + when 'number' + value.is_a?(String) ? (Float(value) rescue value) : value # rubocop:disable Style/RescueModifier + when 'boolean' + return value unless value.is_a?(String) + + lower = value.strip.downcase + return true if TRUE_STRINGS.include?(lower) + return false if FALSE_STRINGS.include?(lower) + + value + when 'array', 'object' + return value unless value.is_a?(String) + + parsed = JSON.parse(value) + expected = type == 'array' ? Array : Hash + parsed.is_a?(expected) ? parsed : value + when 'string' + value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value + else + value + end + rescue JSON::ParserError + value + end + + # Hash results go out as-is; anything else is wrapped as { "result" => value } + def normalize_output(tool_def, output) + data = output.is_a?(Hash) ? output.transform_keys(&:to_s) : { 'result' => output } + begin + JSON.generate(data) + rescue StandardError => e + raise ToolSerializationError, "tool #{tool_def.name} returned a non-JSON-serializable result: #{e.message}" + end + data + end + + def check_credentials!(tool_def) + tool_def.credentials.each { |name| Secrets.secret(name) } + end + + def base_result(task) + result = Http::Models::TaskResult.new + result.task_id = task.task_id + result.workflow_instance_id = task.workflow_instance_id + result.worker_id = WORKER_ID + result + end + + def terminal_failure(result, error) + result.status = Http::Models::TaskResultStatus::FAILED_WITH_TERMINAL_ERROR + result.reason_for_incompletion = error.message + result + end + end + end +end diff --git a/lib/conductor/agents/runtime/execution.rb b/lib/conductor/agents/runtime/execution.rb new file mode 100644 index 0000000..1b3dbaa --- /dev/null +++ b/lib/conductor/agents/runtime/execution.rb @@ -0,0 +1,265 @@ +# frozen_string_literal: true + +require 'timeout' +require_relative '../errors' +require_relative 'approval_request' + +module Conductor + module Agents + # Token usage for an execution (summed over sub-agent executions) + TokenUsage = Struct.new(:prompt_tokens, :completion_tokens, :total_tokens, keyword_init: true) do + def initialize(prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) + super + end + + def +(other) + TokenUsage.new(prompt_tokens: prompt_tokens + other.prompt_tokens, + completion_tokens: completion_tokens + other.completion_tokens, + total_tokens: total_tokens + other.total_tokens) + end + end + + # Maps the server's status + output.finishReason to a Symbol + module FinishReason + def self.derive(status, output) + case status.to_s + when 'COMPLETED' + fr = output.is_a?(Hash) ? output['finishReason'].to_s : '' + case fr + when 'rejected' then :rejected + when 'LENGTH', 'MAX_TOKENS' then :length + when 'tool_calls', 'TOOL_CALLS' then :tool_calls + when 'CONTENT_FILTER' then :content_filter + else :stop + end + when 'FAILED' then :error + when 'TERMINATED' then :cancelled + when 'TIMED_OUT' then :timeout + else :stop + end + end + end + + # Handle on a running (or finished) agent execution. + # + # execution = agent.call_async('Refund order A-1029') + # execution.done? # false until finished + # execution.partial_text # streamed text so far + # execution.result # blocks for the answer + # execution.finish_reason # :stop | :rejected | ... + class Execution + TERMINAL_STATUSES = %w[COMPLETED FAILED TERMINATED TIMED_OUT].freeze + + attr_reader :execution_id, :agent_name, :tool_calls, :token_usage, :pending, :error, :status, :output, :events + + # @param execution_id [String] also the Conductor workflow id + # @param client [Client::AgentClient] + def initialize(execution_id, client:, agent_name: nil, runtime: nil) + @execution_id = execution_id + @client = client + @agent_name = agent_name + @runtime = runtime + @mutex = Mutex.new + @done_cv = ConditionVariable.new + @partial_text = +'' + @tool_calls = [] + @events = [] + @token_usage = TokenUsage.new + @pending = nil + @status = 'RUNNING' + @output = nil + @result = nil + @error = nil + @waiting = false + @finished = false + end + + # Look up an execution by id (a snapshot; +result+ attaches a stream if still running) + # @return [Execution] + def self.find(execution_id, runtime: Conductor::Agents.runtime) + execution = new(execution_id, client: runtime.client, runtime: runtime) + execution.refresh! + execution + end + + # Re-read status from the server + def refresh! + status = @client.get_status(@execution_id) + @agent_name ||= status['agentName'] + if status['isComplete'] + finish(status: status['status'], output: status['output'], reason: status['reasonForIncompletion']) + elsif status['isWaiting'] + mark_waiting(ApprovalRequest.new(@execution_id, status['pendingTool'], client: @client, execution: self)) + else + clear_waiting + end + self + end + + def done? + @mutex.synchronize { @finished } + end + + def waiting? + @mutex.synchronize { @waiting && !@finished } + end + + # Text streamed so far (never behind +result+ once done) + def partial_text + @mutex.synchronize { @partial_text.dup } + end + + # Block until finished and return the answer + # @param timeout [Numeric, nil] seconds; nil waits forever + # @raise [Error] when the execution failed, was cancelled or timed out on the server + # @raise [Timeout::Error] when +timeout+ elapses first + def result(timeout: nil) + @runtime&.attach(self) unless done? || @attached + @mutex.synchronize do + deadline = timeout && (Time.now + timeout) + until @finished + remaining = deadline && (deadline - Time.now) + raise Timeout::Error, "execution #{@execution_id} still running after #{timeout}s" if remaining && remaining <= 0 + + @done_cv.wait(@mutex, remaining) + end + raise Error, "execution #{@execution_id} #{@status.downcase}: #{@error}" if @error && @status != 'COMPLETED' + + @result + end + end + + # The answer without blocking or raising (nil while running or when failed) + def answer + @mutex.synchronize { @finished ? @result : nil } + end + + # @return [Symbol] :stop | :tool_calls | :length | :content_filter | :rejected | :error | :cancelled | :timeout | nil + def finish_reason + @mutex.synchronize { @finished ? FinishReason.derive(@status, @output) : nil } + end + + def rejected? + finish_reason == :rejected + end + + # ── control ───────────────────────────────────────────────────── + + def pause + @client.pause(@execution_id) + self + end + + def resume + @client.resume(@execution_id) + self + end + + def cancel(reason: 'cancelled by client') + @client.cancel(@execution_id, reason: reason) + self + end + + def stop + @client.stop(@execution_id) + self + end + + def signal(message) + @client.signal(@execution_id, message) + self + end + + # Approve / reject the pending tool call, if any + def approve + (pending || raise(Error, 'nothing is waiting for approval')).approve + end + + def reject(reason = '') + (pending || raise(Error, 'nothing is waiting for approval')).reject(reason) + end + + # ── mutators used by the runtime's stream thread ──────────────── + + def attached! + @attached = true + end + + def record_event(event) + @mutex.synchronize { @events << event } + end + + def append_text(text) + return if text.nil? || text.to_s.empty? + + @mutex.synchronize { @partial_text << text.to_s } + end + + def add_tool_call(name, arguments) + @mutex.synchronize { @tool_calls << ToolCall.new(name: name, arguments: arguments || {}) } + end + + def add_tool_result(name, result) + @mutex.synchronize do + call = @tool_calls.reverse.find { |c| c.name == name && c.result.nil? } + call ? call.result = result : @tool_calls << ToolCall.new(name: name, arguments: {}, result: result) + end + end + + def mark_waiting(pending) + @mutex.synchronize do + @waiting = true + @pending = pending + end + end + + def clear_waiting + @mutex.synchronize do + @waiting = false + @pending = nil + end + end + + def token_usage=(usage) + @mutex.synchronize { @token_usage = usage } + end + + # Mark the execution finished. +output+ is the workflow output ({result, finishReason, ...}). + def finish(status:, output:, reason: nil) + @mutex.synchronize do + return if @finished + + @status = status.to_s + @output = output.is_a?(Hash) ? output : {} + @result = extract_result(output) + @error = reason if reason && !reason.to_s.empty? + @error ||= (@output['error'] || @output['reason']) unless @status == 'COMPLETED' + @error ||= "execution #{@status.downcase}" unless @status == 'COMPLETED' + @partial_text = @result.to_s.dup if @result.is_a?(String) && @partial_text.empty? + @waiting = false + @pending = nil + @finished = true + @done_cv.broadcast + end + end + + def fail(reason) + finish(status: 'FAILED', output: { 'error' => reason }, reason: reason) + end + + def to_s + "#" + end + alias inspect to_s + + private + + def extract_result(output) + return output unless output.is_a?(Hash) + return output['result'] if output.key?('result') + + output + end + end + end +end diff --git a/lib/conductor/agents/runtime/secrets.rb b/lib/conductor/agents/runtime/secrets.rb new file mode 100644 index 0000000..2cf8b0c --- /dev/null +++ b/lib/conductor/agents/runtime/secrets.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require_relative '../errors' +require_relative '../../worker/task_context' + +module Conductor + module Agents + # Read secrets inside a tool body. + # + # tool def create_issue(title: String) + # Github.create_issue(title, token: secret('GH_TOKEN')) + # end + # + # The literal name is also the declaration: the Tools DSL scans the body and puts + # GH_TOKEN on the tool's TaskDef#runtime_metadata. The server resolves it from its + # secret store at poll time and delivers the value on Task#runtime_metadata, which + # TaskContext (thread/fiber-local) exposes to the running tool. Nothing is ever written + # to ENV; for subprocesses use secrets_env. + module Secrets + module_function + + # @param name [String, Symbol] + # @return [String] + # @raise [CredentialNotFoundError] when neither the task nor ENV has it + def secret(name) + key = name.to_s + value = task_secrets[key] + value = ENV.fetch(key, nil) if value.nil? + return value unless value.nil? + + raise CredentialNotFoundError, + "secret #{key.inspect} not found: it was not delivered on the task's runtimeMetadata " \ + "and ENV[#{key.inspect}] is unset. Store it on the server (conductor secrets put #{key} ...) " \ + 'or declare it with add_tool ..., credentials: [...]' + end + + # Environment hash for system / spawn / Open3: { 'GH_TOKEN' => '...' } + # @return [Hash] + def secrets_env(*names) + names.flatten.each_with_object({}) { |n, env| env[n.to_s] = secret(n) } + end + + # Secrets bound for the current task (wire-only Task#runtime_metadata) + # @return [Hash] + def task_secrets + ctx = Conductor::Worker::TaskContext.current + task = ctx&.task + return {} unless task.respond_to?(:runtime_metadata) + + task.runtime_metadata || {} + end + end + end +end diff --git a/lib/conductor/agents/runtime/sse_client.rb b/lib/conductor/agents/runtime/sse_client.rb new file mode 100644 index 0000000..1f62086 --- /dev/null +++ b/lib/conductor/agents/runtime/sse_client.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require 'net/http' +require 'uri' +require 'json' +require 'logger' +require_relative '../errors' + +module Conductor + module Agents + # Server-sent events from GET /api/agent/stream/{executionId}. + # + # Uses a plain Net::HTTP streaming request (the shared Faraday RestClient buffers + # bodies, retries, and has a 120 s total timeout, none of which suit a long-lived + # stream). Auth headers come from the ApiClient so token refresh stays in one place. + # + # Wire format (see AgentStreamRegistry on the server): ":connected" first, then + # "id:\nevent:\ndata:\n\n" frames, a ":heartbeat" comment every 15 s, the + # stream closes after "done" or "error". Last-Event-ID (a bare integer) resumes; without + # it the server replays from the start, so connecting after start loses nothing. + class SseClient + HEARTBEAT_ONLY_TIMEOUT = 15 + RECONNECT_DELAY = 1 + TERMINAL_EVENTS = %w[done error].freeze + READ_TIMEOUT = 60 + OPEN_TIMEOUT = 5 + + # Incremental parser for the SSE wire format + class Parser + def initialize + @buffer = +'' + @event = nil + @id = nil + @data = [] + end + + # Feed a chunk; yields each complete event as { 'event', 'id', 'data' } or { 'heartbeat' => true } + def feed(chunk, &block) + @buffer << chunk + while (idx = @buffer.index("\n")) + line = @buffer.slice!(0..idx).chomp + process_line(line, &block) + end + end + + private + + def process_line(line, &block) + if line.start_with?(':') + yield({ 'heartbeat' => true }) + elsif line.empty? + flush(&block) + elsif (m = line.match(/\A(\w+):\s?(.*)\z/m)) + field = m[1] + value = m[2] + case field + when 'event' then @event = value + when 'id' then @id = value + when 'data' then @data << value + end + end + end + + def flush + return if @data.empty? && @event.nil? + + raw = @data.join("\n") + data = begin + raw.empty? ? {} : JSON.parse(raw) + rescue JSON::ParserError + { 'content' => raw } + end + data = { 'content' => data } unless data.is_a?(Hash) + id = @id.to_s =~ /\A\d+\z/ ? @id.to_i : @id + yield({ 'event' => @event || data['type'], 'id' => id, 'data' => data }) + ensure + @event = nil + @id = nil + @data = [] + end + end + + # @param api_client [Http::ApiClient] supplies base URL, TLS settings and auth headers + def initialize(api_client, logger: nil) + @api_client = api_client + @configuration = api_client.configuration + @logger = logger || Logger.new($stdout, level: Logger::INFO) + end + + # Yield every real event for +execution_id+ until done/error, reconnecting on drops. + # @param last_event_id [Integer, nil] resume point + # @raise [SseUnavailableError] when the first connection fails or only heartbeats arrive + def each_event(execution_id, last_event_id: nil) + return enum_for(:each_event, execution_id, last_event_id: last_event_id) unless block_given? + + first_connect = true + got_real_event = false + + loop do + begin + finished = connect(execution_id, last_event_id) do |event| + if event['heartbeat'] + next unless !got_real_event && Time.now - @connected_at > HEARTBEAT_ONLY_TIMEOUT + + raise SseUnavailableError, "SSE connected but only heartbeats arrived for #{HEARTBEAT_ONLY_TIMEOUT}s" + end + first_connect = false + got_real_event = true + last_event_id = event['id'] if event['id'].is_a?(Integer) + yield event + return if TERMINAL_EVENTS.include?(event['event'].to_s) + end + first_connect = false + return if finished + rescue SseUnavailableError + raise + rescue StandardError => e + raise SseUnavailableError, "SSE unavailable: #{e.class}: #{e.message}" if first_connect + + @logger.warn("SSE connection lost (#{e.class}: #{e.message}), reconnecting in #{RECONNECT_DELAY}s") + end + sleep RECONNECT_DELAY + end + end + + private + + # Open one streaming request. Returns true when a terminal event was seen, false on EOF. + def connect(execution_id, last_event_id) + uri = URI.parse("#{@configuration.server_url}/agent/stream/#{execution_id}") + request = Net::HTTP::Get.new(uri) + request['Accept'] = 'text/event-stream' + request['Cache-Control'] = 'no-cache' + request['Last-Event-ID'] = last_event_id.to_s if last_event_id + auth_headers.each { |k, v| request[k] = v } + + parser = Parser.new + terminal = false + Net::HTTP.start(uri.host, uri.port, **http_options(uri)) do |http| + http.request(request) do |response| + raise SseUnavailableError, "SSE endpoint returned HTTP #{response.code}" unless response.code.to_i == 200 + + @connected_at = Time.now + response.read_body do |chunk| + parser.feed(chunk) do |event| + yield event + terminal = true if TERMINAL_EVENTS.include?(event['event'].to_s) + end + break if terminal + end + end + end + terminal + end + + def auth_headers + return {} unless @configuration.auth_configured? + + @api_client.get_authentication_headers || {} + rescue StandardError => e + @logger.warn("Could not attach auth headers to SSE request: #{e.message}") + {} + end + + def http_options(uri) + opts = { use_ssl: uri.scheme == 'https', read_timeout: READ_TIMEOUT, open_timeout: OPEN_TIMEOUT } + if opts[:use_ssl] + opts[:verify_mode] = @configuration.verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE + opts[:ca_file] = @configuration.ssl_ca_cert if @configuration.ssl_ca_cert + end + opts + end + end + end +end diff --git a/lib/conductor/agents/runtime/status_poller.rb b/lib/conductor/agents/runtime/status_poller.rb new file mode 100644 index 0000000..60087ec --- /dev/null +++ b/lib/conductor/agents/runtime/status_poller.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Conductor + module Agents + # Polling fallback for servers without SSE: turns GET /agent/{id}/status into the + # same event hashes the SseClient yields (waiting, done, error). No partial text. + class StatusPoller + def initialize(client, interval: 0.5, logger: nil) + @client = client + @interval = interval + @logger = logger + end + + def each_event(execution_id) + return enum_for(:each_event, execution_id) unless block_given? + + was_waiting = false + loop do + status = @client.get_status(execution_id) + if status['isComplete'] + yield terminal_event(status) + return + end + + if status['isWaiting'] && !was_waiting + yield({ 'event' => 'waiting', 'id' => nil, 'data' => { 'type' => 'waiting', 'executionId' => execution_id, + 'pendingTool' => status['pendingTool'] || {} } }) + end + was_waiting = status['isWaiting'] ? true : false + sleep(was_waiting ? [@interval * 4, 2.0].min : @interval) + end + end + + private + + def terminal_event(status) + if status['status'].to_s == 'COMPLETED' + { 'event' => 'done', 'id' => nil, + 'data' => { 'type' => 'done', 'executionId' => status['executionId'], 'output' => status['output'] || {} } } + else + { 'event' => 'error', 'id' => nil, + 'data' => { 'type' => 'error', 'executionId' => status['executionId'], 'status' => status['status'], + 'content' => status['reasonForIncompletion'] || "execution #{status['status']}", + 'output' => status['output'] } } + end + end + end + end +end diff --git a/lib/conductor/agents/runtime/system_workers.rb b/lib/conductor/agents/runtime/system_workers.rb new file mode 100644 index 0000000..96ac970 --- /dev/null +++ b/lib/conductor/agents/runtime/system_workers.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'json' +require_relative '../errors' + +module Conductor + module Agents + # Bodies for the compiler-generated SIMPLE tasks the server asks the SDK to serve + # (they appear in requiredWorkers next to the user's tools). Ports of the Python + # SDK's TerminationEntry, GuardrailEntry, CallbackEntry and OnCondition handoff workers. + module SystemWorkers + module_function + + # _termination: { should_continue, reason } + def termination(condition, logger: nil) + lambda do |task| + input = stringify(task.input_data) + context = { 'result' => input['result'].to_s, 'messages' => input['messages'] || [], + 'iteration' => input['iteration'].to_i, 'token_usage' => input['token_usage'] } + begin + outcome = condition.should_terminate(context) + { 'should_continue' => !outcome.should_terminate, 'reason' => outcome.reason.to_s } + rescue StandardError => e + logger&.error("termination condition failed: #{e.class}: #{e.message}") + { 'should_continue' => true, 'reason' => '' } + end + end + end + + # : { passed, message, on_fail, fixed_output, guardrail_name, should_continue } + def guardrail(guardrail, logger: nil) + lambda do |task| + input = stringify(task.input_data) + content = stringify_content(input['content']) + iteration = input['iteration'].to_i + begin + result = guardrail.check(content) + return pass_result if result.passed? + + on_fail = guardrail.on_fail + fixed = result.fixed_output + on_fail = 'raise' if on_fail == 'retry' && iteration >= guardrail.max_retries + on_fail = 'raise' if on_fail == 'fix' && fixed.nil? + { 'passed' => false, 'message' => result.message.to_s, 'on_fail' => on_fail, 'fixed_output' => fixed, + 'guardrail_name' => guardrail.name, 'should_continue' => on_fail == 'retry' } + rescue StandardError => e + logger&.error("guardrail #{guardrail.name} raised: #{e.class}: #{e.message}") + on_fail = guardrail.on_fail + on_fail = 'raise' if on_fail == 'retry' && iteration >= guardrail.max_retries + { 'passed' => false, 'message' => "Guardrail error: #{e.message}", 'on_fail' => on_fail, 'fixed_output' => nil, + 'guardrail_name' => guardrail.name, 'should_continue' => on_fail == 'retry' } + end + end + end + + # _: the callback chain's Hash (or {}) + def callback(chain, logger: nil) + lambda do |task| + input = stringify(task.input_data) + kwargs = {} + kwargs[:messages] = input['messages'] if input.key?('messages') + kwargs[:llm_result] = input['llm_result'] if input.key?('llm_result') + begin + result = chain.call(**kwargs) + result.is_a?(Hash) ? result : {} + rescue StandardError => e + logger&.error("callback failed: #{e.class}: #{e.message}") + {} + end + end + end + + # _handoff_: { handoff, target } + def handoff(condition, logger: nil) + lambda do |task| + input = stringify(task.input_data) + begin + { 'handoff' => condition.should_handoff(input) ? true : false, 'target' => condition.target } + rescue StandardError => e + logger&.error("handoff condition failed: #{e.class}: #{e.message}") + { 'handoff' => false, 'target' => condition.target } + end + end + end + + def pass_result + { 'passed' => true, 'message' => '', 'on_fail' => 'pass', 'fixed_output' => nil, 'guardrail_name' => '', + 'should_continue' => false } + end + + # Function-based routers return the selected sub-agent's name. + def router(callable, agent_names, logger: nil) + lambda do |task| + { 'selected_agent' => callable.call(stringify(task.input_data).fetch('prompt', '')).to_s } + rescue StandardError => e + logger&.error("router failed: #{e.class}: #{e.message}") + { 'selected_agent' => agent_names.first || '' } + end + end + + def stringify(input) + (input || {}).transform_keys(&:to_s) + end + + def stringify_content(content) + return '' if content.nil? + return content if content.is_a?(String) + + JSON.generate(content) + rescue StandardError + content.to_s + end + end + end +end diff --git a/lib/conductor/agents/runtime/tool_call.rb b/lib/conductor/agents/runtime/tool_call.rb new file mode 100644 index 0000000..0964631 --- /dev/null +++ b/lib/conductor/agents/runtime/tool_call.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Conductor + module Agents + # A tool call observed on the stream or awaiting approval + ToolCall = Struct.new(:name, :arguments, :result, keyword_init: true) do + def to_s + "#" + end + alias_method :inspect, :to_s + end + end +end diff --git a/lib/conductor/agents/runtime/tool_registry.rb b/lib/conductor/agents/runtime/tool_registry.rb new file mode 100644 index 0000000..12169e6 --- /dev/null +++ b/lib/conductor/agents/runtime/tool_registry.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require 'set' +require_relative '../errors' +require_relative 'dispatch' +require_relative 'system_workers' +require_relative '../../worker/worker' +require_relative '../../http/models/task_def' + +module Conductor + module Agents + # Turns an agent tree into the Conductor workers this process must run: one per + # local tool (worker/cli tools with a func) and one per compiler-generated system task + # the server listed in requiredWorkers. + class ToolRegistry + SYSTEM_SUFFIX_TERMINATION = '_termination' + + attr_reader :logger + + def initialize(agent_config, logger: nil) + @agent_config = agent_config + @logger = logger + end + + # @param agent [Agent] root agent + # @param required_workers [Array, nil] from the start/deploy response (nil = register everything) + # @param domain [String, nil] task domain for stateful runs (the runId) + # @return [Array] + def workers_for(agent, required_workers: nil, domain: nil) + required = required_workers.nil? ? nil : Set.new(required_workers.map(&:to_s)) + workers = tool_workers(agent, domain: domain) + workers += system_workers(agent, required, domain: domain) + warn_unhandled(required, workers) + workers + end + + # Workers for every local tool in the tree (deduplicated by name) + def tool_workers(agent, domain: nil) + seen = {} + each_agent_with_credentials(agent) do |a, credentials| + a.tools.each do |tool_def| + next unless tool_def.local? + + if seen.key?(tool_def.name) + template = seen[tool_def.name].task_def_template + template.runtime_metadata = (template.runtime_metadata + credentials + tool_def.credentials).uniq + next + end + + seen[tool_def.name] = build_tool_worker(tool_def, credentials: credentials, domain: domain) + end + end + seen.values + end + + # Workers for compiler-generated tasks: termination, custom guardrails, callbacks, on_condition handoffs + def system_workers(agent, required, domain: nil) + workers = [] + agent.all_agents.each do |a| + if a.termination + name = "#{a.name}#{SYSTEM_SUFFIX_TERMINATION}" + workers << build_system_worker(name, SystemWorkers.termination(a.termination, logger: @logger), domain, a) if wanted?(required, name) + end + (a.guardrails + a.tools.flat_map(&:guardrails)).each do |g| + next if g.external? || g.is_a?(RegexGuardrail) || g.is_a?(LlmGuardrail) + + workers << build_system_worker(g.name, SystemWorkers.guardrail(g, logger: @logger), domain, a) if wanted?(required, g.name) + end + if a.router.respond_to?(:call) + name = "#{a.name}_router_fn" + workers << build_system_worker(name, SystemWorkers.router(a.router, a.agents.map(&:name), logger: @logger), domain, a) if wanted?(required, name) + end + a.callback_positions.each do |position| + name = "#{a.name}_#{position}" + next unless wanted?(required, name) + + workers << build_system_worker(name, SystemWorkers.callback(a.callback_chain(position, logger: @logger), logger: @logger), domain, a) + end + handoffs = a.handoffs + handoffs += a.agents.flat_map(&:handoffs) if !a.strategy_set? || a.strategy == Strategy::SWARM + handoffs.each do |h| + next unless h.is_a?(Handoff::OnCondition) + + name = "#{a.name}_handoff_#{h.target}" + workers << build_system_worker(name, SystemWorkers.handoff(h, logger: @logger), domain, a) if wanted?(required, name) + end + end + workers.uniq(&:task_definition_name) + end + + # Task definition for a tool worker (same defaults as the Python SDK) + def task_def_for(name, retry_count: 2, retry_delay_seconds: 2, retry_logic: 'LINEAR_BACKOFF', credentials: []) + Http::Models::TaskDef.new( + name: name, + retry_count: retry_count, + retry_delay_seconds: retry_delay_seconds, + retry_logic: retry_logic, + timeout_seconds: 0, + response_timeout_seconds: 10, + timeout_policy: 'RETRY', + runtime_metadata: credentials.uniq + ) + end + + private + + def wanted?(required, name) + required.nil? || required.include?(name) + end + + def build_tool_worker(tool_def, credentials:, domain:) + credentials = tool_def.credentials + credentials + Worker::Worker.new( + tool_def.name, + ->(task) { Dispatch.run_tool_task(task, tool_def, logger: @logger) }, + **worker_options(domain, @agent_config.worker_thread_count), + task_def_template: task_def_for(tool_def.name, retry_count: tool_def.retry_count, + retry_delay_seconds: tool_def.retry_delay_seconds, + retry_logic: tool_def.retry_logic, credentials: credentials) + ) + end + + def each_agent_with_credentials(agent, inherited = [], &block) + credentials = (inherited + agent.credentials).uniq + yield agent, credentials + children = agent.agents + [agent.router, agent.planner, agent.fallback].grep(Agent) + children += agent.tools.filter_map { |tool| tool.config['agent'] if tool.tool_type == ToolType::AGENT_TOOL }.grep(Agent) + children.each { |child| each_agent_with_credentials(child, credentials, &block) } + end + + def build_system_worker(name, body, domain, agent) + Worker::Worker.new( + name, body, + **worker_options(domain, @agent_config.system_worker_thread_count), + task_def_template: task_def_for(name, credentials: agent.credentials) + ) + end + + # When a run has a runId the server maps every required worker to that domain, so all + # workers of the run poll on it. + def worker_options(domain, thread_count) + { + register_task_def: true, + overwrite_task_def: true, + lease_extend_enabled: true, + poll_interval: @agent_config.worker_poll_interval_ms, + thread_count: thread_count, + domain: domain + } + end + + def warn_unhandled(required, workers) + return if required.nil? + + handled = workers.map(&:task_definition_name) + missing = required.to_a - handled + return if missing.empty? + + @logger&.warn("server requires workers this process does not provide: #{missing.join(', ')} " \ + '(tasks of these types will stay SCHEDULED until some worker serves them)') + end + end + end +end diff --git a/lib/conductor/agents/termination.rb b/lib/conductor/agents/termination.rb new file mode 100644 index 0000000..03f42f4 --- /dev/null +++ b/lib/conductor/agents/termination.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require_relative 'errors' + +module Conductor + module Agents + # Composable rules that decide when an agent loop stops. + # + # stop = Termination::TextMention.new('DONE') | Termination::MaxMessage.new(20) + # Agent.new(..., termination: stop) + # + # Conditions serialize to the server (ConfigSerializer) and the same objects back the + # local _termination worker the server asks for. + module Termination + Result = Struct.new(:should_terminate, :reason, keyword_init: true) do + def initialize(should_terminate:, reason: '') + super + end + end + + # Base class. Context keys: result, messages, iteration, token_usage (String or Symbol keys). + class Condition + def should_terminate(_context) + raise NotImplementedError + end + + def &(other) + And.new(self, other) + end + + def |(other) + Or.new(self, other) + end + + def to_s + "#<#{self.class.name.split('::').last}>" + end + alias inspect to_s + + protected + + def ctx(context, key) + return nil unless context.respond_to?(:key?) + + context.key?(key.to_s) ? context[key.to_s] : context[key.to_sym] + end + end + + # Stop when the output contains +text+ + class TextMention < Condition + attr_reader :text, :case_sensitive + + def initialize(text, case_sensitive: false) + raise ConfigurationError, 'text is required' if text.to_s.empty? + + @text = text.to_s + @case_sensitive = case_sensitive ? true : false + super() + end + + def should_terminate(context) + result = ctx(context, :result).to_s + needle = @text + unless @case_sensitive + result = result.downcase + needle = needle.downcase + end + return Result.new(should_terminate: true, reason: "Text '#{@text}' found in output") if result.include?(needle) + + Result.new(should_terminate: false) + end + end + + # Stop when the whole output (stripped) equals +stop_message+ + class StopMessage < Condition + attr_reader :stop_message + + def initialize(stop_message = 'TERMINATE') + @stop_message = stop_message.to_s + super() + end + + def should_terminate(context) + return Result.new(should_terminate: true, reason: "Stop message '#{@stop_message}' received") if ctx(context, :result).to_s.strip == @stop_message + + Result.new(should_terminate: false) + end + end + + # Stop after +max_messages+ messages (falls back to the loop iteration count) + class MaxMessage < Condition + attr_reader :max_messages + + def initialize(max_messages) + raise ConfigurationError, 'max_messages must be >= 1' unless max_messages.is_a?(Integer) && max_messages >= 1 + + @max_messages = max_messages + super() + end + + def should_terminate(context) + messages = ctx(context, :messages) + count = messages.is_a?(Array) ? messages.size : 0 + count = ctx(context, :iteration).to_i if count.zero? + return Result.new(should_terminate: true, reason: "Message count (#{count}) >= limit (#{@max_messages})") if count >= @max_messages + + Result.new(should_terminate: false) + end + end + + # Stop when token usage crosses a budget + class TokenUsage < Condition + attr_reader :max_total_tokens, :max_prompt_tokens, :max_completion_tokens + + def initialize(max_total_tokens: nil, max_prompt_tokens: nil, max_completion_tokens: nil) + raise ConfigurationError, 'at least one token limit must be specified' if [max_total_tokens, max_prompt_tokens, max_completion_tokens].all?(&:nil?) + + @max_total_tokens = max_total_tokens + @max_prompt_tokens = max_prompt_tokens + @max_completion_tokens = max_completion_tokens + super() + end + + def should_terminate(context) + usage = ctx(context, :token_usage) + return Result.new(should_terminate: false) unless usage.respond_to?(:key?) + + checks = [ + [@max_total_tokens, usage_value(usage, 'total_tokens', 'totalTokens'), 'Total'], + [@max_prompt_tokens, usage_value(usage, 'prompt_tokens', 'promptTokens'), 'Prompt'], + [@max_completion_tokens, usage_value(usage, 'completion_tokens', 'completionTokens'), 'Completion'] + ] + checks.each do |limit, value, label| + next if limit.nil? || value < limit + + return Result.new(should_terminate: true, reason: "#{label} tokens (#{value}) >= limit (#{limit})") + end + Result.new(should_terminate: false) + end + + private + + def usage_value(usage, *keys) + keys.each do |k| + v = usage[k] || usage[k.to_sym] + return v.to_i unless v.nil? + end + 0 + end + end + + # All children must trigger + class And < Condition + attr_reader :conditions + + def initialize(*conditions) + @conditions = conditions.flat_map { |c| c.is_a?(And) ? c.conditions : [c] } + super() + end + + def should_terminate(context) + reasons = [] + @conditions.each do |c| + r = c.should_terminate(context) + return Result.new(should_terminate: false) unless r.should_terminate + + reasons << r.reason unless r.reason.to_s.empty? + end + Result.new(should_terminate: true, reason: reasons.join(' AND ')) + end + end + + # Any child triggers + class Or < Condition + attr_reader :conditions + + def initialize(*conditions) + @conditions = conditions.flat_map { |c| c.is_a?(Or) ? c.conditions : [c] } + super() + end + + def should_terminate(context) + @conditions.each do |c| + r = c.should_terminate(context) + return r if r.should_terminate + end + Result.new(should_terminate: false) + end + end + end + end +end diff --git a/lib/conductor/agents/tool.rb b/lib/conductor/agents/tool.rb new file mode 100644 index 0000000..3255180 --- /dev/null +++ b/lib/conductor/agents/tool.rb @@ -0,0 +1,283 @@ +# frozen_string_literal: true + +require 'json' +require_relative 'errors' + +module Conductor + module Agents + # Wire values for ToolConfig#toolType. Only +worker+ and +cli+ tools run in this + # process; every other type is executed by the Conductor server. + module ToolType + WORKER = 'worker' + HTTP = 'http' + API = 'api' + MCP = 'mcp' + HUMAN = 'human' + AGENT_TOOL = 'agent_tool' + GENERATE_IMAGE = 'generate_image' + GENERATE_AUDIO = 'generate_audio' + GENERATE_VIDEO = 'generate_video' + GENERATE_PDF = 'generate_pdf' + RAG_INDEX = 'rag_index' + RAG_SEARCH = 'rag_search' + PULL_WORKFLOW_MESSAGES = 'pull_workflow_messages' + CLI = 'cli' + + MEDIA = [GENERATE_IMAGE, GENERATE_AUDIO, GENERATE_VIDEO, GENERATE_PDF].freeze + RAG = [RAG_INDEX, RAG_SEARCH].freeze + LOCAL = [WORKER, CLI].freeze + ALL = [WORKER, HTTP, API, MCP, HUMAN, AGENT_TOOL, *MEDIA, *RAG, PULL_WORKFLOW_MESSAGES, CLI].freeze + + def self.valid?(type) + ALL.include?(type.to_s) + end + end + + # A tool call with pre-filled arguments (Agent#prefill_tools) + PrefillToolCall = Struct.new(:tool_name, :arguments, :tool, keyword_init: true) do + def to_h + { 'toolName' => tool_name, 'arguments' => arguments || {} } + end + end + + # A tool an agent can call. This is the developer-facing type: the counterpart of + # the Java SDK's @Tool / HttpTool / McpTool builders and the Python SDK's @tool / + # http_tool / mcp_tool functions. The wire-level tool config the server receives is + # produced by ConfigSerializer and never handed to developers. + # + # Worker tools are created by the Tools DSL (+tool def ...+); server-side tools by + # the factories below (Tool.http, .mcp, .human, .agent, ...). + class Tool + RETRY_POLICIES = %w[fixed linear_backoff exponential_backoff].freeze + RETRY_LOGIC = { + 'fixed' => 'FIXED', + 'linear_backoff' => 'LINEAR_BACKOFF', + 'exponential_backoff' => 'EXPONENTIAL_BACKOFF' + }.freeze + CREDENTIAL_PLACEHOLDER = /\$\{(\w+)\}/ + + attr_accessor :name, :description, :input_schema, :output_schema, :func, + :approval_required, :timeout_seconds, :tool_type, :config, + :guardrails, :credentials, :stateful, :max_calls, + :retry_count, :retry_delay_seconds, :retry_policy + + # @param name [String] tool name; for worker tools this is also the Conductor task name + # @param func [Proc, Method, nil] local implementation; nil for server-side tools + def initialize(name:, description: '', input_schema: nil, output_schema: nil, func: nil, + approval_required: false, timeout_seconds: nil, tool_type: ToolType::WORKER, + config: nil, guardrails: nil, credentials: nil, stateful: false, max_calls: nil, + retry_count: 2, retry_delay_seconds: 2, retry_policy: 'linear_backoff') + raise ConfigurationError, 'tool name is required' if name.nil? || name.to_s.empty? + raise ConfigurationError, "unknown tool_type #{tool_type.inspect}" unless ToolType.valid?(tool_type) + unless RETRY_POLICIES.include?(retry_policy.to_s) || RETRY_LOGIC.value?(retry_policy.to_s) + raise ConfigurationError, "retry_policy must be one of #{RETRY_POLICIES.join(', ')}" + end + + @name = name.to_s + @description = description.to_s + @input_schema = input_schema || {} + @output_schema = output_schema || {} + @func = func + @approval_required = approval_required ? true : false + @timeout_seconds = timeout_seconds + @tool_type = tool_type.to_s + @config = config || {} + @guardrails = Array(guardrails) + @credentials = Array(credentials).map(&:to_s).uniq + @stateful = stateful ? true : false + @max_calls = max_calls + @retry_count = retry_count + @retry_delay_seconds = retry_delay_seconds + @retry_policy = retry_policy.to_s + end + + # True when this tool needs a worker polling in this process + def local? + !@func.nil? && ToolType::LOCAL.include?(@tool_type) + end + + def server_side? + !local? + end + + # Conductor retryLogic value for this tool's retry policy + def retry_logic + RETRY_LOGIC.fetch(@retry_policy) { @retry_policy.upcase } + end + + # Add secret names this tool needs (deduplicated) + # @return [self] + def add_credentials(*names) + @credentials = (@credentials + names.flatten.map(&:to_s)).uniq + self + end + + # A copy of this tool guarded by +guardrails+ (replaces any existing ones) + # @return [Tool] + def with_guardrails(*guardrails) + copy = dup + copy.guardrails = guardrails.flatten + copy + end + + # Build a pre-filled call for Agent#prefill_tools + def call(**args) + PrefillToolCall.new(tool_name: @name, arguments: args.transform_keys(&:to_s), tool: self) + end + + def to_s + "#" + end + alias inspect to_s + + class << self + # Tool backed by an HTTP endpoint; the server makes the call. + # Headers may reference secrets as ${NAME}; every placeholder must be listed in +credentials+. + def http(name, url, description: '', method: 'GET', headers: nil, input_schema: nil, + accept: ['application/json'], content_type: 'application/json', credentials: nil) + creds = Array(credentials).map(&:to_s) + validate_placeholders!(headers, creds) + new( + name: name, description: description, + input_schema: input_schema || { 'type' => 'object', 'properties' => {} }, + tool_type: ToolType::HTTP, + config: { 'url' => url, 'method' => method.to_s.upcase, 'headers' => headers || {}, + 'accept' => accept, 'contentType' => content_type }, + credentials: creds + ) + end + + # Tools discovered from an OpenAPI endpoint; the server does the discovery. + def api(url, name: 'api_tools', description: nil, headers: nil, tool_names: nil, max_tools: 64, credentials: nil) + creds = Array(credentials).map(&:to_s) + validate_placeholders!(headers, creds) + config = { 'url' => url } + config['headers'] = headers if headers + config['tool_names'] = Array(tool_names) if tool_names + config['max_tools'] = max_tools + new(name: name, description: description || "API tools from #{url}", tool_type: ToolType::API, + config: config, credentials: creds) + end + + # Tools served by an MCP server; discovery (LIST_MCP_TOOLS) and calls happen on the server. + def mcp(server_url, name: 'mcp_tools', description: nil, headers: nil, tool_names: nil, + max_tools: 64, credentials: nil) + creds = Array(credentials).map(&:to_s) + validate_placeholders!(headers, creds) + config = { 'server_url' => server_url } + config['headers'] = headers if headers + config['tool_names'] = Array(tool_names) if tool_names + config['max_tools'] = max_tools + new(name: name, description: description || "MCP tools from #{server_url}", tool_type: ToolType::MCP, + config: config, credentials: creds) + end + + # Tool that pauses for a human answer (Conductor HUMAN task) + def human(name, description:, input_schema: nil) + new( + name: name, description: description, tool_type: ToolType::HUMAN, + input_schema: input_schema || { + 'type' => 'object', + 'properties' => { 'question' => { 'type' => 'string', + 'description' => 'The question or request for the human operator.' } }, + 'required' => ['question'] + } + ) + end + + # Another agent exposed as a tool (runs as a sub-workflow) + def agent(agent, name: nil, description: nil, retry_count: nil, retry_delay_seconds: nil, optional: nil) + agent_name = agent.respond_to?(:name) ? agent.name : agent.to_s + config = { 'agent' => agent } + config['retryCount'] = retry_count unless retry_count.nil? + config['retryDelaySeconds'] = retry_delay_seconds unless retry_delay_seconds.nil? + config['optional'] = optional unless optional.nil? + new( + name: name || agent_name, + description: description || "Invoke the #{agent_name} agent", + input_schema: { + 'type' => 'object', + 'properties' => { 'request' => { 'type' => 'string', + 'description' => 'The request or question to send to this agent.' } }, + 'required' => ['request'] + }, + tool_type: ToolType::AGENT_TOOL, + config: config + ) + end + + # Media generation tools (server-side) + def image(name, description:, llm_provider:, model:, input_schema: nil, **defaults) + media(ToolType::GENERATE_IMAGE, 'GENERATE_IMAGE', name, description, llm_provider, model, input_schema, defaults) + end + + def audio(name, description:, llm_provider:, model:, input_schema: nil, **defaults) + media(ToolType::GENERATE_AUDIO, 'GENERATE_AUDIO', name, description, llm_provider, model, input_schema, defaults) + end + + def video(name, description:, llm_provider:, model:, input_schema: nil, **defaults) + media(ToolType::GENERATE_VIDEO, 'GENERATE_VIDEO', name, description, llm_provider, model, input_schema, defaults) + end + + def pdf(name = 'generate_pdf', description: 'Generate a PDF document.', input_schema: nil, **defaults) + new(name: name, description: description, tool_type: ToolType::GENERATE_PDF, + input_schema: input_schema || { 'type' => 'object', 'properties' => {} }, + config: { 'taskType' => 'GENERATE_PDF' }.merge(stringify(defaults))) + end + + # RAG tools (server-side) + def index(name, description:, vector_db:, index:, embedding_model_provider:, embedding_model:, + namespace: 'default_ns', chunk_size: nil, chunk_overlap: nil, dimensions: nil, input_schema: nil) + config = { 'taskType' => 'LLM_INDEX_TEXT', 'vectorDB' => vector_db, 'namespace' => namespace, 'index' => index, + 'embeddingModelProvider' => embedding_model_provider, 'embeddingModel' => embedding_model } + config['chunkSize'] = chunk_size if chunk_size + config['chunkOverlap'] = chunk_overlap if chunk_overlap + config['dimensions'] = dimensions if dimensions + new(name: name, description: description, tool_type: ToolType::RAG_INDEX, + input_schema: input_schema || { 'type' => 'object', 'properties' => {} }, config: config) + end + + def search(name, description:, vector_db:, index:, embedding_model_provider:, embedding_model:, + namespace: 'default_ns', max_results: 5, dimensions: nil, input_schema: nil) + config = { 'taskType' => 'LLM_SEARCH_INDEX', 'vectorDB' => vector_db, 'namespace' => namespace, 'index' => index, + 'embeddingModelProvider' => embedding_model_provider, 'embeddingModel' => embedding_model, + 'maxResults' => max_results } + config['dimensions'] = dimensions if dimensions + new(name: name, description: description, tool_type: ToolType::RAG_SEARCH, + input_schema: input_schema || { 'type' => 'object', 'properties' => {} }, config: config) + end + + # Wait for messages posted to the execution (PULL_WORKFLOW_MESSAGES) + def wait_for_message(name, description:, batch_size: 1, blocking: true) + config = { 'batchSize' => batch_size } + config['blocking'] = false unless blocking + new(name: name, description: description, tool_type: ToolType::PULL_WORKFLOW_MESSAGES, + input_schema: { 'type' => 'object', 'properties' => {} }, config: config) + end + + private + + def media(tool_type, task_type, name, description, llm_provider, model, input_schema, defaults) + new(name: name, description: description, tool_type: tool_type, + input_schema: input_schema || { 'type' => 'object', 'properties' => {} }, + config: { 'taskType' => task_type, 'llmProvider' => llm_provider, 'model' => model }.merge(stringify(defaults))) + end + + def stringify(hash) + hash.transform_keys(&:to_s) + end + + def validate_placeholders!(headers, credentials) + return unless headers + + placeholders = headers.to_s.scan(CREDENTIAL_PLACEHOLDER).flatten.uniq + missing = placeholders - credentials + return if missing.empty? + + raise ConfigurationError, + "Header placeholder(s) #{missing.inspect} not declared in credentials: #{credentials.inspect}" + end + end + end + end +end diff --git a/lib/conductor/agents/tools.rb b/lib/conductor/agents/tools.rb new file mode 100644 index 0000000..91bc1bf --- /dev/null +++ b/lib/conductor/agents/tools.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +require_relative 'errors' +require_relative 'runtime/secrets' +require_relative 'tool' +require_relative 'tools/schema_builder' +require_relative 'tools/secret_scanner' + +module Conductor + module Agents + # The tool DSL. + # + # include Conductor::Agents # top level, or + # module Weather; extend Conductor::Agents::Tools; ... end + # + # tool def get_weather(city: String, units: 'metric') + # { temp_c: 21.0 } + # end + # describe :get_weather, 'Get the current weather for a city.' + # requires_approval :get_weather + # + # +tool+ receives the Symbol that +def+ returns, builds a Tool from the method + # (schema from keyword defaults, secrets from literal secret() calls) and registers it + # both on the receiver (Weather[:get_weather], Weather.tool_defs) and in the global + # registry that Agent#add_tool(:get_weather) consults. + module Tools + TOOL_OPTIONS = %i[name description input_schema output_schema approval_required timeout_seconds credentials + guardrails stateful max_calls retry_count retry_delay_seconds retry_policy external].freeze + + # Weather[:current] on a module that `extend Conductor::Agents::Tools` + module Lookup + # @return [Tool] + def [](name) + fetch_tool(name) + end + end + + class << self + # A module that extends Tools also gets [] and the secret helpers + def extended(base) + base.extend(Lookup) + base.extend(Secrets) + end + + # Global name => Tool registry shared by every scope that defines tools + def registry + @registry ||= {} + end + + def registry_mutex + @registry_mutex ||= Mutex.new + end + + def register(tool_def) + registry_mutex.synchronize { registry[tool_def.name] = tool_def } + tool_def + end + + # @return [Tool, nil] + def lookup(name) + registry_mutex.synchronize { registry[name.to_s] } + end + + # Forget every registered tool (tests) + def clear! + registry_mutex.synchronize { registry.clear } + end + + # Build a Tool from a bound Method + # @param method [Method] + # @param name [String, nil] tool name override + def build(method, name: nil, **options) + unknown = options.keys - TOOL_OPTIONS + raise ConfigurationError, "unknown tool option(s): #{unknown.inspect}" unless unknown.empty? + + tool_name = (name || method.name).to_s + input_schema = options.fetch(:input_schema) { SchemaBuilder.input_schema(method) } + credentials = SecretScanner.scan(method) + + Tool.new( + name: tool_name, + description: options.fetch(:description) { humanize(method.name) }, + input_schema: input_schema, + output_schema: options.fetch(:output_schema) { SchemaBuilder.default_output_schema }, + func: options[:external] ? nil : method, + approval_required: options.fetch(:approval_required, false), + timeout_seconds: options[:timeout_seconds], + credentials: credentials + Array(options[:credentials]), + guardrails: Array(options[:guardrails]), + stateful: options.fetch(:stateful, false), + max_calls: options[:max_calls], + retry_count: options.fetch(:retry_count, 2), + retry_delay_seconds: options.fetch(:retry_delay_seconds, 2), + retry_policy: options.fetch(:retry_policy, 'linear_backoff') + ) + end + + # "get_weather" => "Get weather" + def humanize(name) + words = name.to_s.tr('_', ' ').strip + return '' if words.empty? + + words[0].upcase + words[1..] + end + end + + # Mark a method as a tool + # @param name [Symbol, String, Method] method name (what +def+ returns) or a Method + # @param options [Hash] Tool overrides: name: (tool name when it differs from the method name), + # description:, output_schema:, approval_required:, timeout_seconds:, credentials:, guardrails:, + # stateful:, max_calls:, retry_count:, retry_delay_seconds:, retry_policy:, external: + # @return [Tool] + def tool(name, **options) + method = name.is_a?(Method) ? name : resolve_tool_method(name.to_sym) + tool_name = options.delete(:name) || (name.is_a?(Method) ? name.name : name) + tool_def = Tools.build(method, name: tool_name, **options) + tool_registry[tool_def.name] = tool_def + Tools.register(tool_def) + end + + # Override the description the LLM sees + def describe(name, text) + fetch_tool(name).description = text.to_s + end + + # Require a human approval before the tool runs + def requires_approval(name, enabled: true) + fetch_tool(name).approval_required = enabled + end + + # Declare secret names the scanner could not see (dynamic names) + def tool_credentials(name, *secret_names) + fetch_tool(name).add_credentials(*secret_names) + end + + # Every tool defined in this scope, in definition order + # @return [Array] + def tool_defs + tool_registry.values + end + + private + + def tool_registry + @conductor_tool_registry ||= {} # rubocop:disable Naming/MemoizedInstanceVariableName + end + + def fetch_tool(name) + tool_registry[name.to_s] || Tools.lookup(name) || + raise(ConfigurationError, "no tool named #{name.inspect}; define it with `tool def #{name}(...)` first") + end + + # Find the method behind +tool def name+ for the current receiver: + # - top level / objects: the method is on self + # - module with `extend Tools`: `def` made an instance method; module_function it + # - class bodies (e.g. inside RSpec.describe): bind the instance method to a bare instance + def resolve_tool_method(name) + return method(name) if respond_to?(name, true) + + if is_a?(Module) && (method_defined?(name) || private_method_defined?(name)) + if instance_of?(Module) + module_function(name) + return method(name) + end + + return instance_method(name).bind(allocate) + end + + raise ConfigurationError, "tool #{name.inspect}: no such method on #{inspect}" + end + end + end +end diff --git a/lib/conductor/agents/tools/schema_builder.rb b/lib/conductor/agents/tools/schema_builder.rb new file mode 100644 index 0000000..60c2f1c --- /dev/null +++ b/lib/conductor/agents/tools/schema_builder.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +module Conductor + module Agents + module Tools + # Builds a JSON schema for a tool method from its keyword arguments. + # + # Ruby exposes keyword names and whether they are required (+Method#parameters+) but + # never the default expressions, and the Tools DSL uses the default as the type: + # + # tool def get_weather(city: String, units: 'metric', limit: 10, tags: [String], mode: %w[a b]) + # + # so the defaults are read from the method's AST (RubyVM::AbstractSyntaxTree.of). That + # works on MRI whenever the method's source file is on disk (and for eval'd code on + # Ruby >= 3.2 with RubyVM.keep_script_lines = true). When the AST is unavailable the + # builder falls back to Python's behaviour: every keyword becomes an untyped property + # ({}), required when the keyword has no default. + module SchemaBuilder # rubocop:disable Metrics/ModuleLength + CLASS_TYPES = { + 'String' => 'string', 'Symbol' => 'string', + 'Integer' => 'integer', + 'Float' => 'number', 'Numeric' => 'number', 'BigDecimal' => 'number', + 'TrueClass' => 'boolean', 'FalseClass' => 'boolean', + 'Hash' => 'object', 'Array' => 'array', + 'Time' => 'string', 'Date' => 'string', 'DateTime' => 'string' + }.freeze + + POSITIONAL = %i[req opt rest].freeze + + module_function + + # @param method [Method, UnboundMethod] + # @return [Hash] JSON schema for the tool input + def input_schema(method) + params = method.parameters + positional = params.select { |kind, _| POSITIONAL.include?(kind) } + unless positional.empty? + raise ConfigurationError, + "tool #{method.name}: use keyword arguments only (found positional #{positional.map(&:last).inspect})" + end + + defaults = keyword_defaults(method) + properties = {} + required = [] + + params.each do |kind, name| + case kind + when :keyreq + properties[name.to_s] = defaults.key?(name) ? schema_for(defaults[name]) : {} + required << name.to_s + when :key + if defaults.key?(name) + schema, is_required = schema_for_default(defaults[name]) + properties[name.to_s] = schema + required << name.to_s if is_required + else + properties[name.to_s] = {} + end + end + end + + schema = { 'type' => 'object', 'properties' => properties } + schema['required'] = required unless required.empty? + schema + end + + # Default output schema for worker tools: Dispatch always returns a JSON object + def default_output_schema + { 'type' => 'object', 'additionalProperties' => {} } + end + + # Map keyword name => default AST node, or {} when no AST is available + def keyword_defaults(method) + ast = ast_of(method) + return {} unless ast + + args = find_node(ast, :ARGS) + return {} unless args + + kw = args.children[7] + result = {} + each_node(kw) do |node| + next unless node.type == :KW_ARG + + lasgn = node.children[0] + next unless lasgn.respond_to?(:type) && lasgn.type == :LASGN + + name, default = lasgn.children + # required keywords (city:) carry a Symbol placeholder instead of a default node + result[name] = default if default.is_a?(RubyVM::AbstractSyntaxTree::Node) + end + result + end + + def ast_of(method) + return nil unless defined?(RubyVM::AbstractSyntaxTree) + + RubyVM::AbstractSyntaxTree.of(method) + rescue StandardError + nil + end + + # Schema for a default that stands for a *type* (class constant or array of one) + def schema_for(node) + schema_for_default(node).first + end + + # @return [Array(Hash, Boolean)] schema and whether the parameter is required + def schema_for_default(node) + case node.type + when :CONST + type = CLASS_TYPES[node.children[0].to_s] + [type ? { 'type' => type } : {}, true] + when :COLON2 + type = CLASS_TYPES[node.children[1].to_s] + [type ? { 'type' => type } : {}, true] + when :STR + [{ 'type' => 'string', 'default' => node.children[0] }, false] + when :DSTR, :XSTR, :DXSTR + [{ 'type' => 'string' }, false] + when :LIT, :INTEGER, :FLOAT, :RATIONAL, :IMAGINARY + literal_schema(node.children[0]) + when :TRUE + [{ 'type' => 'boolean', 'default' => true }, false] + when :FALSE + [{ 'type' => 'boolean', 'default' => false }, false] + when :NIL + [{}, false] + when :LIST, :ZLIST + list_schema(node) + when :HASH + [{ 'type' => 'object', 'default' => {} }, false] + else + [{}, false] + end + end + + def literal_schema(value) + case value + when Integer then [{ 'type' => 'integer', 'default' => value }, false] + when Float then [{ 'type' => 'number', 'default' => value }, false] + when Symbol then [{ 'type' => 'string', 'default' => value.to_s }, false] + when Regexp then [{ 'type' => 'string', 'pattern' => value.source }, false] + else [{}, false] + end + end + + def list_schema(node) + elements = node.type == :ZLIST ? [] : node.children.compact + return [{ 'type' => 'array', 'default' => [] }, false] if elements.empty? + + if elements.size == 1 && %i[CONST COLON2].include?(elements[0].type) + item, = schema_for_default(elements[0]) + return [{ 'type' => 'array', 'items' => item }, true] + end + + if elements.all? { |e| e.type == :STR } + values = elements.map { |e| e.children[0] } + return [{ 'type' => 'string', 'enum' => values, 'default' => values.first }, false] + end + + if elements.all? { |e| %i[LIT INTEGER].include?(e.type) && e.children[0].is_a?(Integer) } + values = elements.map { |e| e.children[0] } + return [{ 'type' => 'integer', 'enum' => values, 'default' => values.first }, false] + end + + [{ 'type' => 'array' }, false] + end + + def find_node(node, type) + found = nil + each_node(node) do |n| + if n.type == type + found = n + break + end + end + found + end + + def each_node(node, &block) + return unless node.is_a?(RubyVM::AbstractSyntaxTree::Node) + + block.call(node) + node.children.each { |child| each_node(child, &block) } + end + end + end + end +end diff --git a/lib/conductor/agents/tools/secret_scanner.rb b/lib/conductor/agents/tools/secret_scanner.rb new file mode 100644 index 0000000..279f320 --- /dev/null +++ b/lib/conductor/agents/tools/secret_scanner.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Conductor + module Agents + module Tools + # Finds the secret names a tool body reads, so they can be declared on the wire + # (TaskDef#runtime_metadata / tool.config.credentials) without a separate list. + # + # Only string literals are picked up: + # + # secret('GH_TOKEN') # => GH_TOKEN + # secrets_env('A', 'B') # => A, B + # secret(name) # dynamic: declare with add_tool ..., credentials: [...] + module SecretScanner + SECRET_METHODS = %i[secret secrets_env].freeze + + module_function + + # @param method [Method, UnboundMethod] + # @return [Array] literal secret names, in order of appearance + def scan(method) + ast = SchemaBuilder.ast_of(method) + return [] unless ast + + names = [] + SchemaBuilder.each_node(ast) do |node| + method_id, args = call_parts(node) + next unless SECRET_METHODS.include?(method_id) + + SchemaBuilder.each_node(args) { |a| names << a.children[0] if a.type == :STR } + end + names.uniq + end + + def call_parts(node) + case node.type + when :FCALL then [node.children[0], node.children[1]] + when :CALL, :QCALL then [node.children[1], node.children[2]] + else [nil, nil] + end + end + end + end + end +end diff --git a/lib/conductor/client/agent_client.rb b/lib/conductor/client/agent_client.rb new file mode 100644 index 0000000..350bacf --- /dev/null +++ b/lib/conductor/client/agent_client.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require_relative '../exceptions' +require_relative '../http/api/agent_resource_api' + +module Conductor + module Client + # AgentClient - High-level client for the server-side agent runtime. + # + # Mirrors the Python SDK's OrkesAgentClient: hashes in, hashes out, and every + # transport error is re-raised as AgentApiError (AgentNotFoundError on 404). + class AgentClient + attr_reader :agent_api + + # @param api_client [Http::ApiClient] + def initialize(api_client) + @api_client = api_client + @agent_api = Http::Api::AgentResourceApi.new(api_client) + end + + # @param payload [Hash] AgentStartRequest + # @return [Hash] { "executionId", "agentName", "requiredWorkers" } + def start_agent(payload) + wrap { @agent_api.start(payload) } + end + + # @return [Hash] { "agentName", "requiredWorkers" } + def deploy_agent(payload) + wrap { @agent_api.deploy(payload) } + end + + # @return [Hash] { "workflowDef", "requiredWorkers" } + def compile_agent(payload) + wrap { @agent_api.compile(payload) } + end + + def get_status(execution_id) + wrap { @agent_api.status(execution_id) } + end + + # Stream raw agent events; runtime consumers can instead use call_async(on_event:). + def stream_sse(execution_id, last_event_id: nil, &block) + require_relative '../agents/runtime/sse_client' + Agents::SseClient.new(@api_client).each_event(execution_id, last_event_id: last_event_id, &block) + end + + def get_execution(execution_id) + wrap { @agent_api.execution(execution_id) } + end + + def list_executions(params = {}) + wrap { @agent_api.executions(params) } + end + + # Respond to a waiting execution. Hashes pass through; anything else is wrapped as + # { "output" => value } like the Python client does. + def respond(execution_id, body) + payload = body.is_a?(Hash) ? body : { 'output' => body } + wrap { @agent_api.respond(execution_id, payload) } + end + + def approve(execution_id) + respond(execution_id, { 'approved' => true }) + end + + def reject(execution_id, reason = '') + respond(execution_id, { 'approved' => false, 'reason' => reason.to_s }) + end + + def send_message(execution_id, message) + respond(execution_id, { 'message' => message.to_s }) + end + + def stop(execution_id) + wrap { @agent_api.stop(execution_id) } + end + + def signal(execution_id, message) + wrap { @agent_api.signal(execution_id, message) } + end + + def pause(execution_id) + wrap { @agent_api.pause(execution_id) } + end + + def resume(execution_id) + wrap { @agent_api.resume(execution_id) } + end + + def cancel(execution_id, reason: nil) + wrap { @agent_api.cancel(execution_id, reason: reason) } + end + + def list_agents + wrap { @agent_api.list } + end + + def get_agent(name, version: nil) + wrap { @agent_api.get_agent(name, version: version) } + end + + def delete_agent(name, version: nil) + wrap { @agent_api.delete(name, version: version) } + end + + private + + def wrap + yield + rescue AgentApiError + raise + rescue ApiError => e + raise AgentApiError.from_api_error(e) + end + end + end +end diff --git a/lib/conductor/client/task_client.rb b/lib/conductor/client/task_client.rb index 4210646..918c1bb 100644 --- a/lib/conductor/client/task_client.rb +++ b/lib/conductor/client/task_client.rb @@ -45,6 +45,13 @@ def update_task(task_result) @task_api.update_task(task_result) end + # Update task status using the v2 endpoint (supports lease extension) + # @param [TaskResult] task_result Task result + # @return [Task, nil] Next task for this worker, if any + def update_task_v2(task_result) + @task_api.update_task_v2(task_result) + end + # Get task details # @param [String] task_id Task ID # @return [Task] Task object diff --git a/lib/conductor/configuration.rb b/lib/conductor/configuration.rb index ac3eddb..62a71fb 100644 --- a/lib/conductor/configuration.rb +++ b/lib/conductor/configuration.rb @@ -5,12 +5,43 @@ module Conductor # Configuration for Conductor client class Configuration - # Class-level auth token cache (shared across instances, like Python SDK) + # Legacy process-wide token cache. Tokens are now cached per Configuration + # instance so that two configurations (different servers or credentials) in + # one process never share a token. The class-level accessors remain for one + # release as a compatibility shim and warn once when used. @auth_token = nil @token_update_time = 0 class << self - attr_accessor :auth_token, :token_update_time + def auth_token + legacy_token_cache_warning + @auth_token + end + + def auth_token=(token) + legacy_token_cache_warning + @auth_token = token + end + + def token_update_time + legacy_token_cache_warning + @token_update_time + end + + def token_update_time=(time) + legacy_token_cache_warning + @token_update_time = time + end + + private + + def legacy_token_cache_warning + return if @legacy_token_cache_warned + + @legacy_token_cache_warned = true + warn '[Conductor] Configuration.auth_token / token_update_time are deprecated: ' \ + 'the auth token is cached per Configuration instance.' + end end attr_accessor :base_url, :server_api_url, :debug, :authentication_settings, @@ -29,6 +60,8 @@ def initialize(base_url: nil, server_api_url: nil, debug: false, @key_file = nil @proxy = nil @auth_token_ttl_min = auth_token_ttl_min + @auth_token = nil + @token_update_time = 0 # Resolve server URL @host = resolve_host(server_api_url, base_url) @@ -50,18 +83,18 @@ def disable_auth! @authentication_settings = nil end + # Cache an auth token on this configuration instance + # @param token [String] JWT returned by the /token endpoint def update_token(token) - self.class.auth_token = token - self.class.token_update_time = (Time.now.to_f * 1000).to_i + @auth_token = token + @token_update_time = (Time.now.to_f * 1000).to_i end - def auth_token - self.class.auth_token - end + # @return [String, nil] The cached auth token for this configuration + attr_reader :auth_token - def token_update_time - self.class.token_update_time - end + # @return [Integer] Epoch milliseconds of the last token update (0 when never set) + attr_reader :token_update_time # Alias for server URL (used in some places) def server_url diff --git a/lib/conductor/exceptions.rb b/lib/conductor/exceptions.rb index 174f76d..952192f 100644 --- a/lib/conductor/exceptions.rb +++ b/lib/conductor/exceptions.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'json' + module Conductor # Base exception for all Conductor errors class ConductorError < StandardError; end @@ -71,6 +73,40 @@ def build_message end end + # Error returned by the agent REST API (/api/agent/*). The server answers 4xx with + # {"error": "", "status": }; +error+ carries that message when present. + class AgentApiError < ApiError + attr_reader :error + + def initialize(message = nil, status: nil, code: nil, reason: nil, body: nil, headers: nil) + @error = parse_error(body) + super(message || @error, status: status, code: code, reason: reason, body: body, headers: headers) + end + + # Build from a generic ApiError raised by the transport layer + # @param error [ApiError] + # @return [AgentApiError] + def self.from_api_error(error) + klass = error.not_found? ? AgentNotFoundError : AgentApiError + klass.new(error.message, status: error.status, code: error.code, reason: error.reason, + body: error.body, headers: error.headers) + end + + private + + def parse_error(body) + return nil unless body.is_a?(String) && !body.empty? + + data = JSON.parse(body) + data['error'] if data.is_a?(Hash) + rescue JSON::ParserError + nil + end + end + + # Agent, execution, or deployment not found (404 from /api/agent/*) + class AgentNotFoundError < AgentApiError; end + # Non-retryable worker error (terminal failure) class NonRetryableError < ConductorError; end diff --git a/lib/conductor/http/api/agent_resource_api.rb b/lib/conductor/http/api/agent_resource_api.rb new file mode 100644 index 0000000..b8cd3d2 --- /dev/null +++ b/lib/conductor/http/api/agent_resource_api.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require_relative '../api_client' + +module Conductor + module Http + module Api + # AgentResourceApi - REST bindings for the server-side agent runtime (/api/agent/*) + # + # Every method returns the parsed JSON body as a Hash (or Array) so that callers + # see exactly the keys the server sent (executionId, requiredWorkers, isComplete, ...). + # The SSE stream endpoint is not here: it needs a long-lived streaming connection and + # lives in Conductor::Agents::Runtime::SseClient. + class AgentResourceApi + HASH = 'Hash' + + attr_accessor :api_client + + def initialize(api_client = nil) + @api_client = api_client || ApiClient.new + end + + # Start an agent execution + # @param body [Hash] AgentStartRequest: agentConfig | name, prompt, sessionId, media, context, runId, ... + # @return [Hash] { "executionId", "agentName", "requiredWorkers" } + def start(body) + post('/agent/start', body) + end + + # Register (deploy) an agent definition without starting it + # @param body [Hash] AgentStartRequest with agentConfig + # @return [Hash] { "agentName", "requiredWorkers" } + def deploy(body) + post('/agent/deploy', body) + end + + # Compile an agent config into a workflow definition without registering it + # @param body [Hash] AgentStartRequest with agentConfig + # @return [Hash] { "workflowDef", "requiredWorkers" } + def compile(body) + post('/agent/compile', body) + end + + # Get the status of an execution + # @return [Hash] { "executionId", "status", "isComplete", "isRunning", "isWaiting", "output", "pendingTool", ... } + def status(execution_id) + get('/agent/{executionId}/status', execution_id) + end + + # Get an execution with its tasks and token usage + # @return [Hash] { "executionId", "status", "output", "tokenUsage", "tasks" } + def execution(execution_id) + get('/agent/execution/{executionId}', execution_id) + end + + # List executions + # @param params [Hash] start, size, sort, freeText, status, agentName, sessionId + # @return [Hash] { "totalHits", "results" } + def executions(params = {}) + @api_client.call_api('/agent/executions', 'GET', query_params: params, return_type: HASH, + return_http_data_only: true) + end + + # Respond to a waiting execution (approval, human input, free text) + # @param body [Hash] e.g. { "approved" => true } or { "approved" => false, "reason" => "..." } + def respond(execution_id, body) + post_action(execution_id, 'respond', body) + end + + # Ask the agent loop to stop after the current iteration + def stop(execution_id) + post_action(execution_id, 'stop') + end + + # Inject a signal message into the next LLM turn + def signal(execution_id, message) + post_action(execution_id, 'signal', { 'message' => message }) + end + + # Pause the execution + def pause(execution_id) + @api_client.call_api('/agent/{executionId}/pause', 'PUT', path_params: { executionId: execution_id }, + return_http_data_only: true) + end + + # Resume a paused execution + def resume(execution_id) + @api_client.call_api('/agent/{executionId}/resume', 'PUT', path_params: { executionId: execution_id }, + return_http_data_only: true) + end + + # Cancel (terminate) the execution + def cancel(execution_id, reason: nil) + query = reason ? { reason: reason } : {} + @api_client.call_api('/agent/{executionId}/cancel', 'DELETE', path_params: { executionId: execution_id }, + query_params: query, return_http_data_only: true) + end + + # List deployed agents + # @return [Array] + def list + @api_client.call_api('/agent/list', 'GET', return_type: 'Array', return_http_data_only: true) + end + + # Get a deployed agent definition by name + # @return [Hash] the agentConfig as deployed + def get_agent(name, version: nil) + query = version ? { version: version } : {} + @api_client.call_api('/agent/{name}', 'GET', path_params: { name: name }, query_params: query, + return_type: HASH, return_http_data_only: true) + end + + # Delete a deployed agent definition + def delete(name, version: nil) + query = version ? { version: version } : {} + @api_client.call_api('/agent/{name}', 'DELETE', path_params: { name: name }, query_params: query, + return_http_data_only: true) + end + + private + + def get(path, execution_id) + @api_client.call_api(path, 'GET', path_params: { executionId: execution_id }, return_type: HASH, + return_http_data_only: true) + end + + def post(path, body) + @api_client.call_api(path, 'POST', body: body, return_type: HASH, return_http_data_only: true) + end + + def post_action(execution_id, action, body = nil) + @api_client.call_api("/agent/{executionId}/#{action}", 'POST', path_params: { executionId: execution_id }, + body: body, return_http_data_only: true) + end + end + end + end +end diff --git a/lib/conductor/http/api/task_resource_api.rb b/lib/conductor/http/api/task_resource_api.rb index b0adc11..c096825 100644 --- a/lib/conductor/http/api/task_resource_api.rb +++ b/lib/conductor/http/api/task_resource_api.rb @@ -73,6 +73,21 @@ def update_task(body) ) end + # Update task status using the v2 endpoint (POST /tasks/update-v2) + # Supports lease extension via TaskResult#extend_lease and returns the next + # task for the same worker when the server has one queued. + # @param [TaskResult] body Task result + # @return [Task, nil] Next task if the server returned one, nil on 204 + def update_task_v2(body) + @api_client.call_api( + '/tasks/update-v2', + 'POST', + body: body, + return_type: 'Task', + return_http_data_only: true + ) + end + # Get task details # @param [String] task_id Task ID # @return [Task] Task object diff --git a/lib/conductor/http/models/task.rb b/lib/conductor/http/models/task.rb index 27667df..0912efe 100644 --- a/lib/conductor/http/models/task.rb +++ b/lib/conductor/http/models/task.rb @@ -50,7 +50,8 @@ class Task < BaseModel first_start_time: 'Integer', loop_over_task: 'Boolean', task_definition: 'TaskDef', - queue_wait_time: 'Integer' + queue_wait_time: 'Integer', + runtime_metadata: 'Hash' }.freeze ATTRIBUTE_MAP = { @@ -96,7 +97,8 @@ class Task < BaseModel first_start_time: :firstStartTime, loop_over_task: :loopOverTask, task_definition: :taskDefinition, - queue_wait_time: :queueWaitTime + queue_wait_time: :queueWaitTime, + runtime_metadata: :runtimeMetadata }.freeze attr_accessor :task_type, :status, :input_data, :reference_task_name, @@ -112,6 +114,11 @@ class Task < BaseModel :iteration, :sub_workflow_id, :subworkflow_changed, :parent_task_id, :first_start_time, :loop_over_task, :task_definition, :queue_wait_time + # Wire-only map of secret name => resolved value. The server fills it at poll + # time from TaskDef#runtime_metadata (names) and never persists it. + # @return [Hash] + attr_accessor :runtime_metadata + # Initialize a new Task # @param [Hash] attributes Model attributes in the form of hash def initialize(attributes = {}) @@ -125,6 +132,7 @@ def initialize(attributes = {}) # Set default values for collections @input_data ||= {} @output_data ||= {} + @runtime_metadata ||= {} end # Check if task is in terminal state diff --git a/lib/conductor/http/models/task_def.rb b/lib/conductor/http/models/task_def.rb index 18912ac..b26f2bd 100644 --- a/lib/conductor/http/models/task_def.rb +++ b/lib/conductor/http/models/task_def.rb @@ -37,7 +37,9 @@ class TaskDef < BaseModel execution_name_space: 'String', owner_email: 'String', poll_timeout_seconds: 'Integer', - backoff_scale_factor: 'Integer' + backoff_scale_factor: 'Integer', + enforce_schema: 'Boolean', + runtime_metadata: 'Array' }.freeze ATTRIBUTE_MAP = { @@ -58,7 +60,9 @@ class TaskDef < BaseModel execution_name_space: :executionNameSpace, owner_email: :ownerEmail, poll_timeout_seconds: :pollTimeoutSeconds, - backoff_scale_factor: :backoffScaleFactor + backoff_scale_factor: :backoffScaleFactor, + enforce_schema: :enforceSchema, + runtime_metadata: :runtimeMetadata }.freeze attr_accessor :name, :description, :retry_count, :timeout_seconds, @@ -67,7 +71,12 @@ class TaskDef < BaseModel :concurrent_exec_limit, :rate_limit_per_frequency, :rate_limit_frequency_in_seconds, :isolation_group_id, :execution_name_space, :owner_email, :poll_timeout_seconds, - :backoff_scale_factor + :backoff_scale_factor, :enforce_schema + + # Names of secrets the server must resolve and attach to every task of this + # type (delivered as Task#runtime_metadata). Requires conductor-oss >= 3.32.0-rc.8. + # @return [Array] + attr_accessor :runtime_metadata def initialize(params = {}) @name = params[:name] @@ -88,6 +97,8 @@ def initialize(params = {}) @owner_email = params[:owner_email] @poll_timeout_seconds = params[:poll_timeout_seconds] @backoff_scale_factor = params[:backoff_scale_factor] || 1 + @enforce_schema = params.fetch(:enforce_schema, false) + @runtime_metadata = params[:runtime_metadata] || [] end end end diff --git a/lib/conductor/orkes/orkes_clients.rb b/lib/conductor/orkes/orkes_clients.rb index 4e29864..8617dee 100644 --- a/lib/conductor/orkes/orkes_clients.rb +++ b/lib/conductor/orkes/orkes_clients.rb @@ -61,6 +61,10 @@ def get_schema_client Client::SchemaClient.new(@api_client) end + def get_agent_client + Client::AgentClient.new(@api_client) + end + def get_workflow_executor Workflow::WorkflowExecutor.new(@configuration) end diff --git a/lib/conductor/worker/lease_renewer.rb b/lib/conductor/worker/lease_renewer.rb new file mode 100644 index 0000000..fd97e21 --- /dev/null +++ b/lib/conductor/worker/lease_renewer.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'concurrent' +require_relative '../http/models/task_result' + +module Conductor + module Worker + # Renews a task's lease while its worker body runs. Each active task has one + # interruptible heartbeat thread, independent of the polling/execution pool. + class LeaseRenewer + INTERVAL_FACTOR = 0.8 + + def initialize(task_client:, logger:) + @task_client = task_client + @logger = logger + end + + def during(task, worker_id:) + interval = task.response_timeout_seconds.to_f * INTERVAL_FACTOR + return yield unless interval.positive? + + stopped = Concurrent::Event.new + heartbeat = Thread.new do + Thread.current.name = "conductor-lease-#{task.task_id}" + delay = interval + # Retry transient failures within the remaining lease window. + delay = renew(task, worker_id) ? interval : [interval / 4, 1.0].min until stopped.wait(delay) + end + yield + ensure + stopped&.set + # Drain an in-flight heartbeat before the caller can submit a final result. + heartbeat&.join + end + + private + + def renew(task, worker_id) + result = Http::Models::TaskResult.new( + task_id: task.task_id, + workflow_instance_id: task.workflow_instance_id, + worker_id: worker_id, + status: Http::Models::TaskResultStatus::IN_PROGRESS, + extend_lease: true + ) + # The original endpoint handles lease updates without claiming more work. + @task_client.update_task(result) + true + rescue StandardError => e + @logger.warn("Lease renewal failed for task #{task.task_id}: #{e.class}: #{e.message}") + false + end + end + end +end diff --git a/lib/conductor/worker/task_definition_registrar.rb b/lib/conductor/worker/task_definition_registrar.rb index 2883039..3f51754 100644 --- a/lib/conductor/worker/task_definition_registrar.rb +++ b/lib/conductor/worker/task_definition_registrar.rb @@ -210,7 +210,7 @@ def register_or_update_task_def(task_def) raise unless e.status == 404 # Task def doesn't exist, create it - @metadata_client.register_task_def([task_def]) + @metadata_client.register_task_def(task_def) end # Register task def only if it doesn't exist @@ -222,7 +222,7 @@ def register_if_not_exists(task_def) raise unless e.status == 404 # Task def doesn't exist, create it - @metadata_client.register_task_def([task_def]) + @metadata_client.register_task_def(task_def) end end diff --git a/lib/conductor/worker/task_runner.rb b/lib/conductor/worker/task_runner.rb index 32fe0c5..971cba6 100644 --- a/lib/conductor/worker/task_runner.rb +++ b/lib/conductor/worker/task_runner.rb @@ -10,6 +10,7 @@ require_relative 'task_context' require_relative 'task_in_progress' require_relative 'worker_config' +require_relative 'lease_renewer' require_relative 'events/task_runner_events' require_relative 'events/sync_event_dispatcher' require_relative 'events/listener_registry' @@ -43,6 +44,7 @@ def initialize(worker, configuration:, event_dispatcher: nil, logger: nil) # Create task client for API communication @task_client = Client::TaskClient.new(@configuration) + @lease_renewer = LeaseRenewer.new(task_client: @task_client, logger: @logger) # Resolve worker configuration resolved_config = WorkerConfig.resolve( @@ -68,6 +70,9 @@ def initialize(worker, configuration:, event_dispatcher: nil, logger: nil) @poll_count = Concurrent::AtomicFixnum.new(0) @shutdown = Concurrent::AtomicBoolean.new(false) @mutex = Mutex.new + # Prefer POST /tasks/update-v2 (lease extension, next-task return); fall back + # to POST /tasks once if the server does not serve it (404/405). + @use_update_v2 = Concurrent::AtomicBoolean.new(true) end # Main polling loop (runs until shutdown) @@ -176,6 +181,7 @@ def apply_resolved_config(config) @worker_id = config[:worker_id] @domain = config[:domain] @poll_timeout = config[:poll_timeout] + @lease_extend_enabled = config[:lease_extend_enabled] end # Cleanup completed task futures @@ -305,16 +311,21 @@ def submit_task(task) # Execute a task and update the result # @param task [Hash] Task data from API def execute_and_update(task) - task_result = execute_task(task) + while task + task_result = execute_task(task) - # Skip update for TaskInProgress (task stays in IN_PROGRESS state) - return if task_result.nil? + # Skip update for TaskInProgress (task stays in IN_PROGRESS state) + return if task_result.nil? - # Don't update if result is IN_PROGRESS (will be polled again) - return if task_result.status == Http::Models::TaskResultStatus::IN_PROGRESS && - task_result.callback_after_seconds&.positive? + # Don't update if result is IN_PROGRESS (will be polled again) + return if task_result.status == Http::Models::TaskResultStatus::IN_PROGRESS && + task_result.callback_after_seconds&.positive? - update_task_with_retry(task_result) + # update-v2 has already claimed the returned task. Reuse this executor slot + # rather than dropping it or exceeding the worker's concurrency limit. + response = update_task_with_retry(task_result) + task = response.is_a?(Http::Models::Task) ? response : nil + end end # Execute a task @@ -345,7 +356,11 @@ def execute_task(task) begin # Execute worker - task_result = @worker.execute(task_obj) + task_result = if @lease_extend_enabled + @lease_renewer.during(task_obj, worker_id: @worker_id) { @worker.execute(task_obj) } + else + @worker.execute(task_obj) + end duration_ms = (Time.now - start_time) * 1000 @@ -458,11 +473,11 @@ def update_task_with_retry(task_result) start_time = Time.now begin - @task_client.update_task(task_result) + next_task = send_task_update(task_result) duration_ms = (Time.now - start_time) * 1000 publish_task_update_completed(task_result, duration_ms) - return # Success + return next_task rescue StandardError => e duration_ms = (Time.now - start_time) * 1000 @logger.error("Task update failed (attempt #{attempt + 1}/#{RETRY_BACKOFFS.size}): #{e.message}") @@ -474,6 +489,24 @@ def update_task_with_retry(task_result) end end end + nil + end + + # Send the task result to the server, preferring the v2 endpoint + # @param task_result [TaskResult] + def send_task_update(task_result) + return @task_client.update_task(task_result) unless @use_update_v2.true? && running? + + task_result.extend_lease = false if task_result.extend_lease.nil? + begin + @task_client.update_task_v2(task_result) + rescue ApiError => e + raise unless [404, 405].include?(e.status) + + @logger.info('Server does not support /tasks/update-v2, falling back to /tasks') + @use_update_v2.make_false + @task_client.update_task(task_result) + end end def publish_task_update_completed(task_result, duration_ms) diff --git a/lib/conductor/worker/worker.rb b/lib/conductor/worker/worker.rb index f86a1f4..28bfd38 100644 --- a/lib/conductor/worker/worker.rb +++ b/lib/conductor/worker/worker.rb @@ -22,7 +22,7 @@ class Worker attr_accessor :poll_interval, :thread_count, :domain, :worker_id, :poll_timeout, :register_task_def, :overwrite_task_def, :strict_schema, :paused, :isolation, :executor, - :task_def_template + :task_def_template, :lease_extend_enabled # Default configuration values DEFAULTS = { @@ -36,7 +36,8 @@ class Worker strict_schema: false, paused: false, isolation: :thread, - executor: :thread_pool + executor: :thread_pool, + lease_extend_enabled: false }.freeze # Initialize a worker diff --git a/lib/conductor/worker/worker_config.rb b/lib/conductor/worker/worker_config.rb index 7092a67..62e31bc 100644 --- a/lib/conductor/worker/worker_config.rb +++ b/lib/conductor/worker/worker_config.rb @@ -22,7 +22,8 @@ class WorkerConfig strict_schema: { type: :boolean, default: false }, paused: { type: :boolean, default: false }, isolation: { type: :symbol, default: :thread }, # :thread or :ractor - executor: { type: :symbol, default: :thread_pool } # :thread_pool or :fiber + executor: { type: :symbol, default: :thread_pool }, # :thread_pool or :fiber + lease_extend_enabled: { type: :boolean, default: false } }.freeze class << self diff --git a/scripts/run-agents-playback.sh b/scripts/run-agents-playback.sh new file mode 100755 index 0000000..d599559 --- /dev/null +++ b/scripts/run-agents-playback.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Run all agent examples against an existing playback-enabled Conductor server. +set -euo pipefail +repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +conductor_dir=$(cd "${1:?Usage: run-agents-playback.sh CONDUCTOR_CHECKOUT}" && pwd) +cd "$repo_dir" +export CONDUCTOR_SERVER_URL=${CONDUCTOR_SERVER_URL:-http://localhost:8080/api} +export CONDUCTOR_AGENT_LLM_MODEL=mock/mockLLM +export CONDUCTOR_AGENTS_PLAYBACK=true +export CONDUCTOR_RECORDINGS_DIR="$conductor_dir/llm-recordings" +services_script="$conductor_dir/.github/actions/start-playback-services/start-services.sh" +export GITHUB_REPOS_URL='http://localhost:3002/users/Conductor/repos?per_page=5&sort=updated' +mkdir -p tmp +playback_dir=${CONDUCTOR_PLAYBACK_WORK_DIR:-$(mktemp -d "$repo_dir/tmp/agent-playback.XXXXXX")} +mkdir -p "$playback_dir" +playback_dir=$(cd "$playback_dir" && pwd) +[[ -d "$CONDUCTOR_RECORDINGS_DIR" ]] +curl --fail --silent --show-error --max-time 10 "${CONDUCTOR_SERVER_URL%/api}/health" > /dev/null +ruby_command=() +worker_container="" +if ! command -v bundle > /dev/null; then + worker_container="ruby-agent-workers-$$" + ruby_command=(docker run --rm --network host + -v "$repo_dir:$repo_dir:z" -w "$repo_dir" + -v "$playback_dir:$playback_dir:z" + -v ruby-sdk-bundle:/usr/local/bundle:z + -e CONDUCTOR_SERVER_URL -e CONDUCTOR_AGENT_LLM_MODEL + -e CONDUCTOR_AGENTS_PLAYBACK -e GITHUB_REPOS_URL + -e CONDUCTOR_AUTH_KEY -e CONDUCTOR_AUTH_SECRET) +fi +run_ruby() { + if ((${#ruby_command[@]})); then + "${ruby_command[@]}" ruby:3.3 "$@" + else + "$@" + fi +} +run_ruby bundle check + +pids=() +cleanup() { + if [[ -n "$worker_container" ]]; then + docker stop --time 10 "$worker_container" > /dev/null 2>&1 || true + fi + for pid in "${pids[@]}"; do + kill "$pid" 2>/dev/null || true + done + for pid in "${pids[@]}"; do + wait "$pid" 2>/dev/null || true + done +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +if [[ "${CONDUCTOR_PLAYBACK_SERVICES_STARTED:-false}" == true ]]; then + bash "$services_script" --check +else + CONDUCTOR_PLAYBACK_WORK_DIR="$playback_dir" bash "$services_script" + pids+=("$(cat "$playback_dir/http.pid")" "$(cat "$playback_dir/mcp.pid")") +fi + +if [[ -n "$worker_container" ]]; then + "${ruby_command[@]}" --name "$worker_container" ruby:3.3 \ + bundle exec ruby -Ilib examples/agents/external_workers.rb > "$playback_dir/workers.log" 2>&1 & +else + bundle exec ruby -Ilib examples/agents/external_workers.rb > "$playback_dir/workers.log" 2>&1 & +fi +pids+=("$!") + +echo "Running ALL agent examples against $CONDUCTOR_SERVER_URL" +echo "Logs: $playback_dir" +run_ruby bundle exec rspec spec/integration/agents/ --format documentation \ + --format json --out "$playback_dir/results.json" 2>&1 | tee "$playback_dir/tests.log" diff --git a/spec/conductor/agents/agent_config_spec.rb b/spec/conductor/agents/agent_config_spec.rb new file mode 100644 index 0000000..1a997d6 --- /dev/null +++ b/spec/conductor/agents/agent_config_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'conductor/agents' + +RSpec.describe Conductor::Agents::AgentConfig do + it 'has the Python defaults' do + c = described_class.from_env({}) + expect(c.worker_poll_interval_ms).to eq(100) + expect(c.worker_thread_count).to eq(1) + expect(c.auto_register_integrations).to be false + expect(c.streaming_enabled).to be true + end + + it 'reads CONDUCTOR_AGENT_* variables with Python boolean parsing' do + env = { 'CONDUCTOR_AGENT_WORKER_POLL_INTERVAL' => '250', 'CONDUCTOR_AGENT_WORKER_THREADS' => '4', + 'CONDUCTOR_AGENT_INTEGRATIONS_AUTO_REGISTER' => 'yes', 'CONDUCTOR_AGENT_STREAMING_ENABLED' => 'off' } + c = described_class.from_env(env) + expect(c.worker_poll_interval_ms).to eq(250) + expect(c.worker_thread_count).to eq(4) + expect(c.auto_register_integrations).to be true + expect(c.streaming_enabled).to be false + end + + it 'ignores blank and garbage values' do + c = described_class.from_env('CONDUCTOR_AGENT_WORKER_THREADS' => 'lots', 'CONDUCTOR_AGENT_STREAMING_ENABLED' => ' ') + expect(c.worker_thread_count).to eq(1) + expect(c.streaming_enabled).to be true + end +end diff --git a/spec/conductor/agents/agent_runtime_spec.rb b/spec/conductor/agents/agent_runtime_spec.rb new file mode 100644 index 0000000..d1dcf02 --- /dev/null +++ b/spec/conductor/agents/agent_runtime_spec.rb @@ -0,0 +1,230 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'support/agent_tools' + +RSpec.describe Conductor::Agents::AgentRuntime do + a = Conductor::Agents + let(:configuration) { Conductor::Configuration.new(server_api_url: 'http://localhost:8080/api') } + let(:api_client) { instance_double(Conductor::Http::ApiClient, configuration: configuration) } + let(:client) { instance_double(Conductor::Client::AgentClient) } + let(:handler) { instance_double(Conductor::Worker::TaskHandler, start: nil, stop: nil) } + let(:runtime) do + described_class.new(configuration: configuration, agent_config: a::AgentConfig.new, logger: Logger.new(nil), + api_client: api_client, agent_client: client) + end + let(:agent) do + ag = a::Agent.new(name: 'weather', model: 'openai/gpt-4o-mini', instructions: 'Answer weather questions.') + ag.add_tool :current + ag + end + let(:done_output) { { 'result' => 'Sunny in Lisbon, 21C.', 'finishReason' => 'STOP', 'context' => {}, 'rejectionReason' => nil } } + + def event(type, data = {}) + { 'event' => type, 'id' => 1, 'data' => { 'type' => type, 'executionId' => 'EXEC_1' }.merge(data) } + end + + def stub_stream(*events) + sse = instance_double(Conductor::Agents::SseClient) + allow(Conductor::Agents::SseClient).to receive(:new).and_return(sse) + allow(sse).to receive(:each_event) { |_id, &blk| events.each { |e| blk.call(e) } } + sse + end + + before do + allow(Conductor::Worker::TaskHandler).to receive(:new).and_return(handler) + allow(client).to receive(:get_execution).and_return('tokenUsage' => { 'promptTokens' => 196, 'completionTokens' => 50, + 'totalTokens' => 246 }, 'tasks' => []) + end + + after { runtime.shutdown } + + describe '#call_async / #call_sync' do + it 'starts the agent with the serialized config, registers the required workers and streams to the answer' do + expect(client).to receive(:start_agent) do |payload| + expect(payload['agentConfig']).to eq(a::ConfigSerializer.serialize(agent)) + expect(payload['prompt']).to eq('Weather in Lisbon?') + expect(payload['sessionId']).to eq('') + expect(payload['media']).to eq([]) + expect(payload).not_to have_key('runId') + { 'executionId' => 'EXEC_1', 'agentName' => 'weather', 'requiredWorkers' => ['current'] } + end + expect(Conductor::Worker::TaskHandler).to receive(:new) do |workers:, **_| + expect(workers.map(&:task_definition_name)).to eq(['current']) + handler + end + stub_stream(event('thinking', 'content' => 'weather_llm__1'), + event('tool_call', 'toolName' => 'current', 'args' => { 'method' => 'current', '_agent_state' => {}, 'city' => 'Lisbon' }), + event('tool_result', 'toolName' => 'current', 'result' => { 'temp_c' => 21.0 }), + event('done', 'output' => done_output)) + + answers = [] + execution = runtime.call_async(agent, 'Weather in Lisbon?') { |answer| answers << answer } + expect(execution.result(timeout: 5)).to eq('Sunny in Lisbon, 21C.') + expect(execution.finish_reason).to eq(:stop) + expect(execution.tool_calls.first.arguments).to eq('city' => 'Lisbon') + expect(execution.tool_calls.first.result).to eq('temp_c' => 21.0) + expect(execution.token_usage.total_tokens).to eq(246) + expect(runtime.running_workers).to eq(['current']) + sleep 0.05 until execution.done? && !answers.empty? + expect(answers).to eq(['Sunny in Lisbon, 21C.']) + end + + it 'call_sync returns the answer and passes session_id through' do + expect(client).to receive(:start_agent).with(hash_including('sessionId' => 'cust-77')) + .and_return('executionId' => 'EXEC_1', 'requiredWorkers' => []) + stub_stream(event('done', 'output' => done_output)) + expect(runtime.call_sync(agent, 'hi', session_id: 'cust-77', timeout: 5)).to eq('Sunny in Lisbon, 21C.') + end + + it 'invokes on_approval with an ApprovalRequest and posts the decision' do + support = a::Agent.new(name: 'support', model: 'anthropic/claude-sonnet-4-5') + support.add_tool :issue_refund + decisions = [] + support.on_approval do |req| + decisions << req.amount + req.amount < 100 ? req.approve : req.reject('Needs a manager') + end + + allow(client).to receive(:start_agent).and_return('executionId' => 'EXEC_1', 'requiredWorkers' => ['issue_refund']) + expect(client).to receive(:approve).with('EXEC_1') + stub_stream(event('waiting', 'pendingTool' => { 'taskRefName' => 'support_approval_human', + 'toolCalls' => [{ 'name' => 'issue_refund', 'args' => { 'order_id' => 'A-1029', 'amount' => 49.0 } }] }), + event('done', 'output' => done_output.merge('result' => 'Refunded $49.'))) + + expect(runtime.call_sync(support, 'Refund order A-1029', timeout: 5)).to eq('Refunded $49.') + expect(decisions).to eq([49.0]) + end + + it 'parks the request on execution.pending when no on_approval handler exists and reports rejection' do + support = a::Agent.new(name: 'support', model: 'anthropic/claude-sonnet-4-5') + allow(client).to receive(:start_agent).and_return('executionId' => 'EXEC_1', 'requiredWorkers' => []) + gate = Queue.new + sse = instance_double(a::SseClient) + allow(a::SseClient).to receive(:new).and_return(sse) + allow(sse).to receive(:each_event) do |_id, &blk| + blk.call(event('waiting', 'pendingTool' => { 'toolCalls' => [{ 'name' => 'issue_refund', 'args' => { 'amount' => 500 } }] })) + gate.pop + blk.call(event('done', 'output' => { 'result' => nil, 'finishReason' => 'rejected', 'rejectionReason' => 'Needs a manager' })) + end + + execution = runtime.call_async(support, 'Refund order A-1029') + sleep 0.01 until execution.waiting? + expect(execution.pending.tool_name).to eq('issue_refund') + expect(client).to receive(:reject).with('EXEC_1', 'Needs a manager') + execution.reject('Needs a manager') + gate << :go + expect(execution.result(timeout: 5)).to be_nil + expect(execution.finish_reason).to eq(:rejected) + end + + it 'logs and survives exceptions raised in user callbacks' do + support = a::Agent.new(name: 'support', model: 'm/x') + support.on_approval { |_req| raise 'user bug' } + allow(client).to receive(:start_agent).and_return('executionId' => 'EXEC_1', 'requiredWorkers' => []) + stub_stream(event('waiting', 'pendingTool' => { 'toolCalls' => [{ 'name' => 't', 'args' => {} }] }), + event('done', 'output' => done_output)) + execution = runtime.call_async(support, 'x') { |_| raise 'on_done bug' } + expect(execution.result(timeout: 5)).to eq('Sunny in Lisbon, 21C.') + end + + it 'delivers streaming events and isolates an event listener failure' do + allow(client).to receive(:start_agent).and_return('executionId' => 'EXEC_1', 'requiredWorkers' => []) + stub_stream(event('message', 'content' => 'Sunny'), event('done', 'output' => done_output)) + observed = Queue.new + listener = lambda do |ev| + observed << ev['event'] + raise 'display failed' if ev['event'] == 'message' + end + execution = runtime.call_async(agent, 'hi', on_event: listener) + expect(execution.result(timeout: 5)).to eq('Sunny in Lisbon, 21C.') + runtime.shutdown + expect([observed.pop, observed.pop]).to eq(%w[message done]) + end + + it 'falls back to status polling when SSE is unavailable' do + allow(client).to receive(:start_agent).and_return('executionId' => 'EXEC_1', 'requiredWorkers' => []) + sse = instance_double(a::SseClient) + allow(a::SseClient).to receive(:new).and_return(sse) + allow(sse).to receive(:each_event).and_raise(a::SseUnavailableError, 'no sse') + allow(client).to receive(:get_status).and_return('executionId' => 'EXEC_1', 'status' => 'COMPLETED', + 'isComplete' => true, 'output' => done_output) + expect(runtime.call_sync(agent, 'hi', timeout: 5)).to eq('Sunny in Lisbon, 21C.') + end + + it 'uses polling when streaming is disabled and surfaces server errors' do + quiet = described_class.new(configuration: configuration, agent_config: a::AgentConfig.new(streaming_enabled: false), + logger: Logger.new(nil), api_client: api_client, agent_client: client) + allow(client).to receive(:start_agent).and_return('executionId' => 'EXEC_1', 'requiredWorkers' => []) + allow(client).to receive(:get_status).and_return('executionId' => 'EXEC_1', 'status' => 'FAILED', 'isComplete' => true, + 'reasonForIncompletion' => 'model quota exceeded') + expect { quiet.call_sync(agent, 'hi', timeout: 5) }.to raise_error(a::Error, /model quota exceeded/) + quiet.shutdown + end + + it 'sends a runId and uses it as the worker domain for stateful agents' do + stateful = a::Agent.new(name: 'notes', model: 'm/x', stateful: true) + stateful.add_tool :current + expect(client).to receive(:start_agent) do |payload| + expect(payload['runId']).to match(/\A[0-9a-f]{32}\z/) + { 'executionId' => 'EXEC_1', 'requiredWorkers' => ['current'] } + end + expect(Conductor::Worker::TaskHandler).to receive(:new) do |workers:, **_| + expect(workers.first.domain).to match(/\A[0-9a-f]{32}\z/) + handler + end + stub_stream(event('done', 'output' => done_output)) + runtime.call_sync(stateful, 'remember this', timeout: 5) + end + + it 'does not start a second handler for workers that are already polling' do + allow(client).to receive(:start_agent).and_return('executionId' => 'EXEC_1', 'requiredWorkers' => ['current']) + stub_stream(event('done', 'output' => done_output)) + runtime.call_sync(agent, 'a', timeout: 5) + runtime.call_sync(agent, 'b', timeout: 5) + expect(Conductor::Worker::TaskHandler).to have_received(:new).once + end + end + + describe '#deploy / #compile / #serve' do + it 'deploys each agent and returns the names' do + expect(client).to receive(:deploy_agent).with(hash_including('agentConfig' => hash_including('name' => 'weather'))) + .and_return('agentName' => 'weather', 'requiredWorkers' => ['current']) + expect(runtime.deploy(agent)).to eq(['weather']) + end + + it 'compiles without registering' do + expect(client).to receive(:compile_agent).and_return('workflowDef' => {}, 'requiredWorkers' => []) + expect(runtime.compile(agent)).to include('workflowDef') + end + + it 'serve deploys and starts workers without blocking when asked' do + allow(client).to receive(:deploy_agent).and_return('agentName' => 'weather', 'requiredWorkers' => ['current']) + runtime.serve(agent, blocking: false) + expect(runtime.running_workers).to eq(['current']) + end + end + + describe '#shutdown' do + it 'stops handlers and forgets running workers' do + allow(client).to receive(:deploy_agent).and_return('agentName' => 'weather', 'requiredWorkers' => ['current']) + runtime.serve(agent, blocking: false) + runtime.shutdown + expect(handler).to have_received(:stop) + expect(runtime.running_workers).to eq([]) + end + end +end + +RSpec.describe Conductor::Agents, '.runtime' do + after { described_class.shutdown } + + it 'memoizes a default runtime and can be reconfigured' do + allow(Conductor::Http::ApiClient).to receive(:new).and_return(instance_double(Conductor::Http::ApiClient)) + first = described_class.runtime + expect(described_class.runtime).to equal(first) + reconfigured = described_class.configure(configuration: Conductor::Configuration.new(server_api_url: 'http://x/api')) + expect(reconfigured).not_to equal(first) + expect(described_class.runtime).to equal(reconfigured) + end +end diff --git a/spec/conductor/agents/agent_spec.rb b/spec/conductor/agents/agent_spec.rb new file mode 100644 index 0000000..3ecbf11 --- /dev/null +++ b/spec/conductor/agents/agent_spec.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'support/agent_tools' + +RSpec.describe Conductor::Agents::Agent do + let(:model) { 'openai/gpt-4o' } + + describe '#initialize' do + it 'validates the name pattern and max_turns' do + expect { described_class.new(name: '1bad', model: model) }.to raise_error(Conductor::Agents::ConfigurationError, /name/) + expect { described_class.new(name: 'has space', model: model) }.to raise_error(Conductor::Agents::ConfigurationError) + expect { described_class.new(name: 'ok', model: model, max_turns: 0) }.to raise_error(Conductor::Agents::ConfigurationError, /max_turns/) + expect(described_class.new(name: 'ok-name_1', model: model).name).to eq('ok-name_1') + end + + it 'normalizes the strategy and requires a router for :router' do + expect(described_class.new(name: 'a', model: model).strategy).to eq('handoff') + expect(described_class.new(name: 'a', model: model).strategy_set?).to be false + expect(described_class.new(name: 'a', model: model, strategy: :round_robin).strategy).to eq('round_robin') + expect { described_class.new(name: 'a', model: model, strategy: :zigzag) }.to raise_error(Conductor::Agents::ConfigurationError) + expect { described_class.new(name: 'a', model: model, strategy: :router) }.to raise_error(Conductor::Agents::ConfigurationError, /router/) + end + + it 'accepts tools and agents lists' do + child = described_class.new(name: 'c', model: model) + agent = described_class.new(name: 'a', model: model, tools: [:current], agents: [child]) + expect(agent.tools.map(&:name)).to eq(['current']) + expect(agent.agents).to eq([child]) + end + end + + describe '#add_tool' do + let(:agent) { described_class.new(name: 'a', model: model) } + + it 'accepts a symbol from the global registry, a Tool, a Tools module and an Agent' do + agent.add_tool :current + agent.add_tool Conductor::Agents::Tool.http('fetch', 'http://x') + agent.add_tool SpecTools::Github + agent.add_tool described_class.new(name: 'helper', model: model) + expect(agent.tools.map(&:name)).to eq(%w[current fetch create_issue gh_cli dynamic_secret helper]) + expect(agent.tool('helper').tool_type).to eq('agent_tool') + end + + it 'adds explicit credentials on a copy so the registry tool stays untouched' do + agent.add_tool :create_issue, credentials: ['EXTRA'] + expect(agent.tool('create_issue').credentials).to eq(%w[GH_TOKEN EXTRA]) + expect(SpecTools::Github[:create_issue].credentials).to eq(['GH_TOKEN']) + end + + it 'rejects unknown names, duplicates and junk' do + expect { agent.add_tool :missing }.to raise_error(Conductor::Agents::ConfigurationError, /no tool named/) + agent.add_tool :current + expect { agent.add_tool :current }.to raise_error(Conductor::Agents::ConfigurationError, /duplicate/) + expect { agent.add_tool 42 }.to raise_error(Conductor::Agents::ConfigurationError) + expect { agent.add_tool Comparable }.to raise_error(Conductor::Agents::ConfigurationError, /no tools/) + end + end + + describe 'team sugar' do + it 'add_agent rejects non-agents and duplicate names' do + team = described_class.new(name: 'team') + a = described_class.new(name: 'a', model: model) + team.add_agent(a) + expect { team.add_agent(described_class.new(name: 'a', model: model)) }.to raise_error(Conductor::Agents::ConfigurationError, /duplicate/) + expect { team.add_agent('a') }.to raise_error(Conductor::Agents::ConfigurationError) + team.add_agents(described_class.new(name: 'b', model: model), described_class.new(name: 'c', model: model)) + expect(team.agents.map(&:name)).to eq(%w[a b c]) + end + + it 'hands_off_to builds OnTextMention or OnCondition' do + triage = described_class.new(name: 'triage', model: model) + filer = described_class.new(name: 'filer', model: model) + triage.hands_off_to filer, on: 'ACTIONABLE' + triage.hands_off_to filer, on: ->(ctx) { ctx['iteration'] > 3 } + expect(triage.handoffs[0]).to be_a(Conductor::Agents::Handoff::OnTextMention) + expect(triage.handoffs[0].target).to eq('filer') + expect(triage.handoffs[0].text).to eq('ACTIONABLE') + expect(triage.handoffs[1]).to be_a(Conductor::Agents::Handoff::OnCondition) + end + + it '>> builds a flattened sequential pipeline' do + a = described_class.new(name: 'a', model: model) + b = described_class.new(name: 'b', model: model) + c = described_class.new(name: 'c', model: model) + pipeline = a >> b >> c + expect(pipeline.name).to eq('a_b_c') + expect(pipeline.strategy).to eq('sequential') + expect(pipeline.agents).to eq([a, b, c]) + expect(pipeline.model).to eq(model) + end + end + + describe 'guardrail and termination sugar' do + let(:agent) { described_class.new(name: 'a', model: model) } + + it 'redact adds a fixing regex guardrail' do + agent.redact %w[password api.key] + g = agent.guardrails.first + expect(g).to be_a(Conductor::Agents::RegexGuardrail) + expect(g.on_fail).to eq('fix') + expect(g.position).to eq('output') + expect(g.pattern_strings).to eq(['password', 'api\.key']) + expect(g.name).to eq('a_redact') + end + + it 'stop_when and stop_after combine with OR' do + agent.stop_when 'ISSUE_FILED' + expect(agent.termination).to be_a(Conductor::Agents::Termination::TextMention) + agent.stop_after messages: 12 + expect(agent.termination).to be_a(Conductor::Agents::Termination::Or) + expect(agent.termination.conditions.map(&:class)).to eq([Conductor::Agents::Termination::TextMention, + Conductor::Agents::Termination::MaxMessage]) + end + end + + describe 'callbacks' do + it 'collects positions from handlers and blocks' do + handler = Class.new(Conductor::Agents::CallbackHandler) do + def on_tool_end(**_kwargs) + nil + end + end.new + agent = described_class.new(name: 'a', model: model, callbacks: [handler]) + agent.callback(:before_model) { |**_| nil } + expect(agent.callback_positions).to eq(%w[before_model after_tool]) + expect { agent.callback(:sideways) { nil } }.to raise_error(Conductor::Agents::ConfigurationError) + end + end + + describe '#on_approval' do + it 'stores the handler' do + agent = described_class.new(name: 'a', model: model) + handler = proc { |req| req.approve } + agent.on_approval(&handler) + expect(agent.approval_handler).to eq(handler) + end + end + + describe 'introspection' do + it 'walks the whole tree and detects stateful members' do + leaf = described_class.new(name: 'leaf', model: model, stateful: true) + router = described_class.new(name: 'router', model: model) + tool_agent = described_class.new(name: 'tool_agent', model: model) + root = described_class.new(name: 'root', model: model, agents: [leaf], strategy: :router, router: router) + root.add_tool tool_agent + expect(root.all_agents.map(&:name)).to eq(%w[root leaf router tool_agent]) + expect(root.stateful_tree?).to be true + expect(described_class.new(name: 'x', model: model).stateful_tree?).to be false + end + end + + describe '#call_sync / #call_async' do + it 'delegates to the default runtime' do + runtime = double('runtime') + allow(Conductor::Agents).to receive(:runtime).and_return(runtime) + agent = described_class.new(name: 'a', model: model) + expect(runtime).to receive(:call_sync).with(agent, 'hi', session_id: 's').and_return('answer') + expect(runtime).to receive(:call_async).with(agent, 'hi', session_id: nil) + expect(agent.call_sync('hi', session_id: 's')).to eq('answer') + agent.call_async('hi') + end + end +end diff --git a/spec/conductor/agents/approval_request_spec.rb b/spec/conductor/agents/approval_request_spec.rb new file mode 100644 index 0000000..7f2ddd6 --- /dev/null +++ b/spec/conductor/agents/approval_request_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'conductor/agents' + +RSpec.describe Conductor::Agents::ApprovalRequest do + let(:client) { instance_double(Conductor::Client::AgentClient) } + let(:pending_tool) do + { 'taskRefName' => 'support_approval_human', 'tool_name' => nil, 'parameters' => nil, + 'toolCalls' => [{ 'name' => 'issue_refund', 'args' => { 'order_id' => 'A-1029', 'amount' => 49.0 } }], + 'response_schema' => { 'type' => 'object', 'required' => ['approved'] } } + end + let(:request) { described_class.new('EXEC_1', pending_tool, client: client) } + + it 'reads the batch of tool calls and exposes the first one' do + expect(request.tool_calls.map(&:name)).to eq(['issue_refund']) + expect(request.tool_name).to eq('issue_refund') + expect(request.arguments).to eq('order_id' => 'A-1029', 'amount' => 49.0) + expect(request.amount).to eq(49.0) + expect(request.order_id).to eq('A-1029') + expect(request.respond_to?(:amount)).to be true + expect { request.nonexistent }.to raise_error(NoMethodError) + expect(request.task_ref_name).to eq('support_approval_human') + end + + it 'also understands the singular tool_name/parameters shape' do + single = described_class.new('E', { 'tool_name' => 'ask', 'parameters' => { 'q' => 1 } }, client: client) + expect(single.tool_name).to eq('ask') + expect(single.q).to eq(1) + end + + it 'approves, rejects and sends messages once' do + execution = instance_double(Conductor::Agents::Execution, clear_waiting: nil) + req = described_class.new('EXEC_1', pending_tool, client: client, execution: execution) + expect(client).to receive(:approve).with('EXEC_1') + req.approve + expect(req.responded?).to be true + expect(execution).to have_received(:clear_waiting) + expect { req.approve }.to raise_error(Conductor::Agents::Error, /already/) + + expect(client).to receive(:reject).with('EXEC_1', 'Needs a manager') + described_class.new('EXEC_1', pending_tool, client: client).reject('Needs a manager') + expect(client).to receive(:send_message).with('EXEC_1', 'hello') + described_class.new('EXEC_1', pending_tool, client: client).send_message('hello') + end + + it 'submits structured human responses including reviewer feedback exactly once' do + response = { 'approved' => true, 'reason' => 'Reviewed' } + expect(client).to receive(:respond).with('EXEC_1', response).once + request.respond(response) + expect(request.responded?).to be true + expect { request.respond(response) }.to raise_error(Conductor::Agents::Error, /already/) + end +end diff --git a/spec/conductor/agents/callback_handler_spec.rb b/spec/conductor/agents/callback_handler_spec.rb new file mode 100644 index 0000000..ec688e9 --- /dev/null +++ b/spec/conductor/agents/callback_handler_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'conductor/agents' + +RSpec.describe Conductor::Agents::CallbackHandler do + let(:timing) do + Class.new(described_class) do + def on_model_start(**_kwargs) + { 'seen' => true } + end + end + end + + let(:noisy) do + Class.new(described_class) do + def on_model_start(**_kwargs) + raise 'boom' + end + + def on_model_end(**_kwargs) + nil + end + end + end + + it 'knows which positions a handler overrides' do + handler = timing.new + expect(handler.handles?('before_model')).to be true + expect(handler.handles?(:after_model)).to be false + end + + it 'chains handlers with first-non-empty-hash-wins and skips errors' do + chain = described_class.chain('before_model', [noisy.new, timing.new], logger: Logger.new(nil)) + expect(chain.call(messages: [])).to eq('seen' => true) + end + + it 'returns nil when nothing is registered and {} when handlers return nil' do + expect(described_class.chain('after_agent', [timing.new])).to be_nil + expect(described_class.chain('after_model', [noisy.new]).call(llm_result: 'x')).to eq({}) + end + + it 'runs procs before handlers' do + chain = described_class.chain('before_model', [timing.new], [->(**_) { { 'proc' => 1 } }]) + expect(chain.call).to eq('proc' => 1) + end +end diff --git a/spec/conductor/agents/config_serializer_spec.rb b/spec/conductor/agents/config_serializer_spec.rb new file mode 100644 index 0000000..f5923a9 --- /dev/null +++ b/spec/conductor/agents/config_serializer_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'support/agent_tools' + +RSpec.describe Conductor::Agents::ConfigSerializer do + a = Conductor::Agents + let(:model) { 'openai/gpt-4o' } + + def serialize(agent) + described_class.serialize(agent) + end + + it 'emits the always-present keys and drops nils and empties' do + config = serialize(a::Agent.new(name: 'greeter', model: model)) + expect(config).to eq('name' => 'greeter', 'model' => model, 'maxTurns' => 25, 'timeoutSeconds' => 0, + 'external' => false) + end + + it 'emits strategy only for agents with sub-agents' do + leaf = a::Agent.new(name: 'leaf', model: model, strategy: :parallel) + expect(serialize(leaf)).not_to have_key('strategy') + team = a::Agent.new(name: 'team', model: model, agents: [leaf], strategy: :parallel) + expect(serialize(team)['strategy']).to eq('parallel') + expect(serialize(team)['agents'].first['name']).to eq('leaf') + end + + it 'inherits a missing team model from the first member and rejects model-less leaves' do + team = a::Agent.new(name: 'bug_desk') + team.add_agent a::Agent.new(name: 'triage', model: 'openai/gpt-4o-mini') + team.add_agent a::Agent.new(name: 'filer', model: 'anthropic/claude-sonnet-4-5') + expect(serialize(team)['model']).to eq('openai/gpt-4o-mini') + + expect { serialize(a::Agent.new(name: 'lonely')) }.to raise_error(a::ConfigurationError, /no model/) + expect(serialize(a::Agent.new(name: 'ext', external: true))).not_to have_key('model') + end + + it 'puts agent credentials at the top level and tool credentials under config' do + agent = a::Agent.new(name: 'filer', model: model, credentials: ['GH_TOKEN']) + agent.add_tool :create_issue, credentials: ['EXTRA'] + config = serialize(agent) + expect(config['credentials']).to eq(['GH_TOKEN']) + tool = config['tools'].first + expect(tool['config']).to eq('credentials' => %w[GH_TOKEN EXTRA]) + expect(tool).not_to have_key('credentials') + expect(tool['approvalRequired']).to be true + end + + it 'serializes worker tools with schema, description and default output schema' do + agent = a::Agent.new(name: 'w', model: model, tools: [:current]) + tool = serialize(agent)['tools'].first + expect(tool).to eq( + 'name' => 'current', 'description' => 'Current', 'toolType' => 'worker', + 'inputSchema' => { 'type' => 'object', + 'properties' => { 'city' => { 'type' => 'string' }, + 'units' => { 'type' => 'string', 'default' => 'metric' } }, + 'required' => ['city'] }, + 'outputSchema' => { 'type' => 'object', 'additionalProperties' => {} } + ) + end + + it 'marks tools stateful when the agent is stateful' do + agent = a::Agent.new(name: 'w', model: model, tools: [:current], stateful: true) + expect(serialize(agent)['tools'].first['stateful']).to be true + end + + it 'replaces the agent in an agent_tool config with agentConfig' do + child = a::Agent.new(name: 'child', model: model) + parent = a::Agent.new(name: 'parent', model: model, tools: [a::Tool.agent(child, optional: false)]) + tool = serialize(parent)['tools'].first + expect(tool['toolType']).to eq('agent_tool') + expect(tool['config']['agentConfig']['name']).to eq('child') + expect(tool['config']['optional']).to be false + expect(tool['config']).not_to have_key('agent') + end + + it 'serializes instructions as string, prompt template or callable' do + expect(serialize(a::Agent.new(name: 'x', model: model, instructions: ''))).not_to have_key('instructions') + tpl = a::PromptTemplate.new(name: 'support_prompt', variables: { 'tone' => 'kind' }, version: 2) + expect(serialize(a::Agent.new(name: 'x', model: model, instructions: tpl))['instructions']).to eq( + 'type' => 'prompt_template', 'name' => 'support_prompt', 'variables' => { 'tone' => 'kind' }, 'version' => 2 + ) + expect(serialize(a::Agent.new(name: 'x', model: model, instructions: -> { 'dynamic' }))['instructions']).to eq('dynamic') + end + + it 'serializes guardrails of every kind' do + agent = a::Agent.new( + name: 'g', model: model, + guardrails: [ + a::RegexGuardrail.new('x', name: 'rx', message: 'no x', on_fail: :retry), + a::LlmGuardrail.new(model, 'policy', name: 'llm', max_tokens: 10), + a::Guardrail.new(name: 'custom', on_fail: :fix) { |_| true }, + a::Guardrail.new(name: 'remote', position: :input) + ] + ) + expect(serialize(agent)['guardrails']).to eq([ + { 'name' => 'rx', 'position' => 'output', 'onFail' => 'retry', 'maxRetries' => 3, + 'guardrailType' => 'regex', 'patterns' => ['x'], 'mode' => 'block', 'message' => 'no x' }, + { 'name' => 'llm', 'position' => 'output', 'onFail' => 'raise', 'maxRetries' => 3, + 'guardrailType' => 'llm', 'model' => model, 'policy' => 'policy', 'maxTokens' => 10 }, + { 'name' => 'custom', 'position' => 'output', 'onFail' => 'fix', 'maxRetries' => 3, + 'guardrailType' => 'custom', 'taskName' => 'custom' }, + { 'name' => 'remote', 'position' => 'input', 'onFail' => 'raise', 'maxRetries' => 3, + 'guardrailType' => 'external', 'taskName' => 'remote' } + ]) + end + + it 'serializes handoffs including on_condition task names' do + team = a::Agent.new(name: 'team', model: model, strategy: :swarm, + agents: [a::Agent.new(name: 'b', model: model)], + handoffs: [a::Handoff::OnToolResult.new(target: 'b', tool_name: 't', result_contains: 'x'), + a::Handoff::OnCondition.new(target: 'b') { true }]) + expect(serialize(team)['handoffs']).to eq([ + { 'target' => 'b', 'type' => 'on_tool_result', 'toolName' => 't', 'resultContains' => 'x' }, + { 'target' => 'b', 'type' => 'on_condition', 'taskName' => 'team_handoff_b' } + ]) + end + + it 'hoists member hands_off_to into a swarm team when no strategy was chosen' do + triage = a::Agent.new(name: 'triage', model: model) + filer = a::Agent.new(name: 'filer', model: model) + triage.hands_off_to filer, on: 'ACTIONABLE' + team = a::Agent.new(name: 'bug_desk') + team.add_agents triage, filer + config = serialize(team) + expect(config['strategy']).to eq('swarm') + expect(config['handoffs']).to eq([{ 'target' => 'filer', 'type' => 'on_text_mention', 'text' => 'ACTIONABLE' }]) + + explicit = a::Agent.new(name: 'seq', strategy: :sequential) + explicit.add_agents triage, filer + expect(serialize(explicit)['strategy']).to eq('sequential') + expect(serialize(explicit)).not_to have_key('handoffs') + end + + it 'serializes memory, callbacks, prefill tools, metadata and numeric options' do + memory = a::ConversationMemory.new(max_messages: 5) + memory.add_user_message('hi') + agent = a::Agent.new(name: 'm', model: model, memory: memory, max_tokens: 100, temperature: 0.2, + metadata: { 'team' => 'x' }, prefill_tools: [a::Tool.new(name: 't').call(a: 1)]) + agent.callback(:after_model) { |**_| nil } + config = serialize(agent) + expect(config['memory']).to eq('messages' => [{ 'role' => 'user', 'message' => 'hi' }], 'maxMessages' => 5) + expect(config['callbacks']).to eq([{ 'position' => 'after_model', 'taskName' => 'm_after_model' }]) + expect(config['prefillTools']).to eq([{ 'toolName' => 't', 'arguments' => { 'a' => 1 } }]) + expect(config['metadata']).to eq('team' => 'x') + expect(config['maxTokens']).to eq(100) + expect(config['temperature']).to eq(0.2) + end + + it 'serializes a router agent or router task reference' do + child = a::Agent.new(name: 'c', model: model) + by_agent = a::Agent.new(name: 'r', model: model, agents: [child], strategy: :router, router: child) + expect(serialize(by_agent)['router']['name']).to eq('c') + by_proc = a::Agent.new(name: 'r2', model: model, agents: [child], strategy: :router, router: ->(_ctx) { 'c' }) + expect(serialize(by_proc)['router']).to eq('taskName' => 'r2_router_fn') + end + + it 'serializes output_type from a schema hash' do + schema = { 'title' => 'Report', 'type' => 'object', 'properties' => {} } + expect(serialize(a::Agent.new(name: 'o', model: model, output_type: schema))['outputType']).to eq( + 'schema' => schema, 'className' => 'Report' + ) + expect(serialize(a::Agent.new(name: 'o', model: model, output_type: { class_name: 'X' }))['outputType']).to eq('className' => 'X') + end +end diff --git a/spec/conductor/agents/contract_spec.rb b/spec/conductor/agents/contract_spec.rb new file mode 100644 index 0000000..56926f6 --- /dev/null +++ b/spec/conductor/agents/contract_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'json_schemer' +require 'conductor/agents' +require_relative '../../../examples/agents/golden_agents' + +# Contract tests: the Ruby serializer must produce exactly what the Python SDK produces +# (golden configs vendored from python-sdk/examples/agents/_configs) and every config must +# validate against the published agent schema. +RSpec.describe 'agentConfig contract' do + fixtures = File.expand_path('../../fixtures/agents', __dir__) + schema = JSONSchemer.schema(JSON.parse(File.read(File.join(fixtures, 'agent-schema.json')))) + golden_files = Dir[File.join(fixtures, 'configs', '*.json')] + + it 'has a golden fixture for every example and vice versa' do + expect(golden_files.map { |f| File.basename(f, '.json') }).to match_array(GoldenAgents::EXAMPLES.keys) + end + + golden_files.each do |file| + name = File.basename(file, '.json') + + it "serializes #{name} exactly like the Python SDK" do + expected = JSON.parse(File.read(file)) + actual = JSON.parse(JSON.generate(Conductor::Agents::ConfigSerializer.serialize(GoldenAgents::EXAMPLES.fetch(name).call))) + expect(actual).to eq(expected) + end + + it "#{name} validates against agent-schema.json" do + config = JSON.parse(JSON.generate(Conductor::Agents::ConfigSerializer.serialize(GoldenAgents::EXAMPLES.fetch(name).call))) + errors = schema.validate(config).map { |e| e['error'] } + expect(errors).to eq([]) + end + end + + it 'rejects unknown root keys (the schema is closed)' do + expect(schema.valid?({ 'name' => 'x', 'bogus' => 1 })).to be false + end +end diff --git a/spec/conductor/agents/dispatch_spec.rb b/spec/conductor/agents/dispatch_spec.rb new file mode 100644 index 0000000..b8925a0 --- /dev/null +++ b/spec/conductor/agents/dispatch_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'support/agent_tools' + +RSpec.describe Conductor::Agents::Dispatch do + # Agent tool task input, including server-injected routing fields + let(:recorded_input) do + { '_agent_tool_name' => 'get_weather', '_agent_state' => {}, 'method' => 'get_weather', + 'city' => 'Lisbon', 'units' => 'metric' } + end + + def task_with(input, runtime_metadata: {}) + Conductor::Http::Models::Task.from_hash('taskId' => 'TASK_1', 'workflowInstanceId' => 'EXEC_1', + 'taskType' => 'get_weather', 'inputData' => input, + 'runtimeMetadata' => runtime_metadata) + end + + def with_context(task) + result = Conductor::Http::Models::TaskResult.new + Conductor::Worker::TaskContext.current = Conductor::Worker::TaskContext.new(task, result) + yield + ensure + Conductor::Worker::TaskContext.clear + end + + it 'strips the injected keys and runs the tool with keyword arguments' do + result = described_class.run_tool_task(task_with(recorded_input), SpecTools::Weather[:current]) + expect(result.status).to eq('COMPLETED') + expect(result.worker_id).to eq('agent-sdk') + expect(result.task_id).to eq('TASK_1') + expect(result.workflow_instance_id).to eq('EXEC_1') + expect(result.output_data).to eq('temp_c' => 21.0, 'summary' => 'Sunny in Lisbon (metric)') + end + + it 'fails terminally when a required argument is missing' do + result = described_class.run_tool_task(task_with({ 'units' => 'metric' }), SpecTools::Weather[:current]) + expect(result.status).to eq('FAILED_WITH_TERMINAL_ERROR') + expect(result.reason_for_incompletion).to include('city') + end + + it 'coerces strings to the schema types and JSON to strings' do + td = SpecTools::Weather[:forecast] + input = { 'city' => 'Porto', 'days' => '5', 'detailed' => 'yes', 'tags' => '["a","b"]', 'ratio' => '0.25', + 'opts' => '{"k":1}', 'mode' => 'full' } + result = described_class.run_tool_task(task_with(input), td) + expect(result.output_data).to include('days' => 5, 'detailed' => true, 'tags' => %w[a b], 'ratio' => 0.25, + 'opts' => { 'k' => 1 }, 'mode' => 'full') + end + + it 'wraps scalar results and keeps _state_updates' do + scalar = Conductor::Agents::Tool.new(name: 's', func: ->(**) { 'plain' }, + input_schema: { 'type' => 'object', 'properties' => {} }) + expect(described_class.run_tool_task(task_with({}), scalar).output_data).to eq('result' => 'plain') + + stateful = Conductor::Agents::Tool.new(name: 's2', func: ->(**) { { ok: true, _state_updates: { 'n' => 1 } } }, + input_schema: { 'type' => 'object', 'properties' => {} }) + expect(described_class.run_tool_task(task_with({}), stateful).output_data).to eq('ok' => true, '_state_updates' => { 'n' => 1 }) + end + + it 'reports tool exceptions as retryable failures with the reason' do + boom = Conductor::Agents::Tool.new(name: 'boom', func: ->(**) { raise 'kaput' }, + input_schema: { 'type' => 'object', 'properties' => {} }) + result = described_class.run_tool_task(task_with({}), boom, logger: Logger.new(nil)) + expect(result.status).to eq('FAILED') + expect(result.reason_for_incompletion).to eq('RuntimeError: kaput') + end + + it 'fails terminally on unserializable results' do + bad = Conductor::Agents::Tool.new(name: 'bad', func: ->(**) { { io: $stdout } }, + input_schema: { 'type' => 'object', 'properties' => {} }) + allow(JSON).to receive(:generate).and_raise(JSON::GeneratorError, 'nope') + result = described_class.run_tool_task(task_with({}), bad) + expect(result.status).to eq('FAILED_WITH_TERMINAL_ERROR') + end + + it 'reads declared secrets from the task runtimeMetadata via TaskContext' do + task = task_with({ 'title' => 'bug', 'method' => 'create_issue' }, runtime_metadata: { 'GH_TOKEN' => 'ghp_x' }) + with_context(task) do + result = described_class.run_tool_task(task, SpecTools::Github[:create_issue]) + expect(result.status).to eq('COMPLETED') + expect(result.output_data['token']).to eq('ghp_x') + end + end + + it 'falls back to ENV and fails terminally when a declared secret is missing everywhere' do + task = task_with({ 'title' => 'bug' }) + with_context(task) do + ENV['GH_TOKEN'] = 'from_env' + expect(described_class.run_tool_task(task, SpecTools::Github[:create_issue]).output_data['token']).to eq('from_env') + ensure + ENV.delete('GH_TOKEN') + end + with_context(task) do + result = described_class.run_tool_task(task, SpecTools::Github[:create_issue]) + expect(result.status).to eq('FAILED_WITH_TERMINAL_ERROR') + expect(result.reason_for_incompletion).to include('GH_TOKEN') + end + end + + it 'passes unknown keys only to tools that accept **kwargs' do + strict = Conductor::Agents::Tool.new(name: 'strict', func: ->(a:) { { a: a } }, + input_schema: { 'type' => 'object', 'properties' => { 'a' => {} } }) + expect(described_class.run_tool_task(task_with({ 'a' => 1, 'zzz' => 2 }), strict).output_data).to eq('a' => 1) + loose = Conductor::Agents::Tool.new(name: 'loose', func: ->(a:, **rest) { { a: a, rest: rest } }, + input_schema: { 'type' => 'object', 'properties' => { 'a' => {} } }) + expect(described_class.run_tool_task(task_with({ 'a' => 1, 'zzz' => 2 }), loose).output_data).to eq('a' => 1, 'rest' => { zzz: 2 }) + end +end + +RSpec.describe Conductor::Agents::Secrets do + it 'secrets_env returns only the requested names' do + ENV['S_A'] = '1' + ENV['S_B'] = '2' + expect(described_class.secrets_env('S_A', 'S_B')).to eq('S_A' => '1', 'S_B' => '2') + ensure + ENV.delete('S_A') + ENV.delete('S_B') + end + + it 'raises CredentialNotFoundError with guidance' do + expect { described_class.secret('NOPE_NOT_SET') }.to raise_error(Conductor::Agents::CredentialNotFoundError, /conductor secrets put/) + end +end diff --git a/spec/conductor/agents/examples_spec.rb b/spec/conductor/agents/examples_spec.rb new file mode 100644 index 0000000..46e2559 --- /dev/null +++ b/spec/conductor/agents/examples_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'conductor/agents' +require 'json_schemer' +require_relative '../../../examples/agents/catalog' + +RSpec.describe 'Runnable agent example contracts' do + AgentExamples::EXAMPLES.each_key do |name| + it "matches Python's #{name} configurations and validates against the agent schema" do + example = AgentExamples.load(name) + actual = Array(example.build(model: 'openai/gpt-4o-mini')).map do |agent| + JSON.parse(JSON.generate(Conductor::Agents::ConfigSerializer.serialize(agent))) + end + fixtures = File.expand_path('../../fixtures/agents', __dir__) + expected = JSON.parse(File.read(File.join(fixtures, 'examples', "#{name}.json"))) + expect(actual).to eq(expected) + schema = JSONSchemer.schema(JSON.parse(File.read(File.join(fixtures, 'agent-schema.json')))) + actual.each { |config| expect(schema.validate(config).to_a).to eq([]) } + end + end + + it 'loads all requested examples without starting workers or making HTTP calls' do + expect(Conductor::Agents::AgentRuntime).not_to receive(:new) + expect(AgentExamples::EXAMPLES.size).to eq(19) + AgentExamples::EXAMPLES.each_key do |name| + Array(AgentExamples.load(name).build).each do |agent| + expect(Conductor::Agents::ConfigSerializer.serialize(agent)).to include('name', 'model') + end + end + end + + it 'keeps external worker declarations out of the local worker pool' do + agent = AgentExamples.load('33_external_workers').build + registry = Conductor::Agents::ToolRegistry.new(Conductor::Agents::AgentConfig.new) + expect(registry.tool_workers(agent).map(&:task_definition_name)).to eq(['format_response']) + external = agent.tool('check_inventory') + expect(external.func).to be_nil + expect(external.input_schema['required']).to eq(['product_id']) + expect(external.input_schema['properties']).to have_key('warehouse') + expect(Conductor::Agents::ConfigSerializer.serialize(agent)['tools'].map { |t| t['toolType'] }).to eq(['worker'] * 4) + end + + it 'preserves the three retry policies on registered task definitions' do + agent = AgentExamples.load('02c_tool_retry_config').build + registry = Conductor::Agents::ToolRegistry.new(Conductor::Agents::AgentConfig.new) + definitions = registry.tool_workers(agent).map(&:task_def_template) + expect(definitions.map(&:retry_count)).to eq([5, 3, 2]) + expect(definitions.map(&:retry_delay_seconds)).to eq([1, 5, 2]) + expect(definitions.map(&:retry_logic)).to eq(%w[EXPONENTIAL_BACKOFF FIXED LINEAR_BACKOFF]) + end +end diff --git a/spec/conductor/agents/execution_spec.rb b/spec/conductor/agents/execution_spec.rb new file mode 100644 index 0000000..9e986d7 --- /dev/null +++ b/spec/conductor/agents/execution_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'conductor/agents' + +RSpec.describe Conductor::Agents::Execution do + let(:client) { instance_double(Conductor::Client::AgentClient) } + let(:execution) { described_class.new('EXEC_1', client: client, agent_name: 'weather') } + + it 'blocks in result until finished and exposes the answer' do + Thread.new do + sleep 0.05 + execution.finish(status: 'COMPLETED', output: { 'result' => 'Sunny', 'finishReason' => 'STOP' }) + end + expect(execution.done?).to be false + expect(execution.result(timeout: 2)).to eq('Sunny') + expect(execution.done?).to be true + expect(execution.finish_reason).to eq(:stop) + expect(execution.partial_text).to eq('Sunny') + end + + it 'times out when asked to' do + expect { execution.result(timeout: 0.05) }.to raise_error(Timeout::Error) + end + + it 'raises on failed executions and maps finish reasons' do + execution.finish(status: 'FAILED', output: {}, reason: 'LLM exploded') + expect { execution.result }.to raise_error(Conductor::Agents::Error, /LLM exploded/) + expect(execution.finish_reason).to eq(:error) + + rejected = described_class.new('E2', client: client) + rejected.finish(status: 'COMPLETED', output: { 'result' => nil, 'finishReason' => 'rejected', 'rejectionReason' => 'no' }) + expect(rejected.finish_reason).to eq(:rejected) + expect(rejected.rejected?).to be true + + expect(Conductor::Agents::FinishReason.derive('COMPLETED', 'finishReason' => 'MAX_TOKENS')).to eq(:length) + expect(Conductor::Agents::FinishReason.derive('TERMINATED', nil)).to eq(:cancelled) + expect(Conductor::Agents::FinishReason.derive('TIMED_OUT', nil)).to eq(:timeout) + end + + it 'pairs tool calls with their results' do + execution.add_tool_call('get_weather', 'city' => 'Lisbon') + execution.add_tool_result('get_weather', 'temp_c' => 21) + expect(execution.tool_calls.size).to eq(1) + expect(execution.tool_calls.first.arguments).to eq('city' => 'Lisbon') + expect(execution.tool_calls.first.result).to eq('temp_c' => 21) + expect(execution.tool_calls.first.to_s).to include('get_weather') + end + + it 'tracks waiting and delegates control calls to the client' do + request = instance_double(Conductor::Agents::ApprovalRequest, approve: true) + execution.mark_waiting(request) + expect(execution.waiting?).to be true + expect(execution.pending).to eq(request) + execution.approve + execution.clear_waiting + expect(execution.waiting?).to be false + expect { execution.reject }.to raise_error(Conductor::Agents::Error) + + expect(client).to receive(:pause).with('EXEC_1') + expect(client).to receive(:resume).with('EXEC_1') + expect(client).to receive(:cancel).with('EXEC_1', reason: 'bye') + expect(client).to receive(:stop).with('EXEC_1') + expect(client).to receive(:signal).with('EXEC_1', 'hurry') + execution.pause.resume.cancel(reason: 'bye').stop.signal('hurry') + end + + it 'loads a snapshot with .find' do + runtime = instance_double(Conductor::Agents::AgentRuntime, client: client) + allow(client).to receive(:get_status).with('EXEC_9').and_return( + 'executionId' => 'EXEC_9', 'agentName' => 'weather', 'status' => 'COMPLETED', 'isComplete' => true, + 'output' => { 'result' => 'done!', 'finishReason' => 'STOP' } + ) + found = described_class.find('EXEC_9', runtime: runtime) + expect(found.done?).to be true + expect(found.result).to eq('done!') + expect(found.agent_name).to eq('weather') + end + + describe 'waiting snapshots' do + let(:pending_tool) do + { 'taskRefName' => 'refund_approval__1', 'response_schema' => { 'type' => 'object' }, + 'toolCalls' => [{ 'name' => 'refund', 'args' => { 'amount' => 49 } }] } + end + let(:runtime) { instance_double(Conductor::Agents::AgentRuntime, client: client) } + + before do + allow(client).to receive(:get_status).with('EXEC_1').and_return( + 'isComplete' => false, 'isWaiting' => true, 'pendingTool' => pending_tool + ) + end + + it 'loads an ApprovalRequest object that can approve and clear waiting state' do + found = described_class.find('EXEC_1', runtime: runtime) + expect(found.pending).to be_a(Conductor::Agents::ApprovalRequest) + expect(found.pending.task_ref_name).to eq('refund_approval__1') + expect(found.pending.tool_calls.first).to be_a(Conductor::Agents::ToolCall) + expect(found.pending.amount).to eq(49) + expect(found.pending.response_schema).to eq('type' => 'object') + expect(client).to receive(:approve).with('EXEC_1') + + request = found.approve + expect(request).to be_responded + expect(found).not_to be_waiting + expect(found.pending).to be_nil + end + + it 'can reject a refreshed approval' do + execution.refresh! + expect(client).to receive(:reject).with('EXEC_1', 'Needs a manager') + execution.reject('Needs a manager') + expect(execution).not_to be_waiting + expect(execution.pending).to be_nil + end + + it 'clears an approval answered by another client when refreshed' do + execution.refresh! + allow(client).to receive(:get_status).with('EXEC_1').and_return('isComplete' => false, 'isWaiting' => false) + execution.refresh! + expect(execution).not_to be_waiting + expect(execution.pending).to be_nil + end + end +end diff --git a/spec/conductor/agents/guardrail_spec.rb b/spec/conductor/agents/guardrail_spec.rb new file mode 100644 index 0000000..05bae94 --- /dev/null +++ b/spec/conductor/agents/guardrail_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'conductor/agents' + +RSpec.describe Conductor::Agents::Guardrail do + it 'validates position, on_fail, human-on-input and max_retries' do + expect { described_class.new(name: 'g', position: :middle) }.to raise_error(Conductor::Agents::ConfigurationError) + expect { described_class.new(name: 'g', on_fail: :explode) }.to raise_error(Conductor::Agents::ConfigurationError) + expect { described_class.new(name: 'g', position: :input, on_fail: :human) }.to raise_error(Conductor::Agents::ConfigurationError) + expect { described_class.new(name: 'g', max_retries: 0) }.to raise_error(Conductor::Agents::ConfigurationError) + expect { described_class.new }.to raise_error(Conductor::Agents::ConfigurationError) + end + + it 'is external without a block and custom with one' do + external = described_class.new(name: 'remote') + expect(external.external?).to be true + expect(external.guardrail_type).to eq('external') + expect { external.check('x') }.to raise_error(Conductor::Agents::Error) + + custom = described_class.new(name: 'no_pii', on_fail: :retry) { |c| !c.include?('ssn') } + expect(custom.guardrail_type).to eq('custom') + expect(custom.check('fine').passed?).to be true + expect(custom.check('ssn 1').passed?).to be false + expect(custom.on_fail).to eq('retry') + expect(custom.position).to eq('output') + end +end + +RSpec.describe Conductor::Agents::RegexGuardrail do + it 'blocks matches in block mode with the custom or default message' do + g = described_class.new(['\d{3}-\d{2}-\d{4}'], name: 'no_ssn', message: 'No SSNs') + expect(g.check('my ssn is 123-45-6789').message).to eq('No SSNs') + expect(g.check('nothing here').passed?).to be true + expect(described_class.new('x').check('x').message).to eq('Content matched a blocked pattern.') + end + + it 'requires a match in allow mode and accepts Regexp patterns' do + g = described_class.new(/^\s*[{\[]/, mode: :allow) + expect(g.check('{"a":1}').passed?).to be true + expect(g.check('nope').message).to eq('Content did not match any allowed pattern.') + expect(g.pattern_strings).to eq(['^\s*[{\[]']) + expect(g.guardrail_type).to eq('regex') + end + + it 'rejects invalid modes' do + expect { described_class.new('x', mode: :maybe) }.to raise_error(Conductor::Agents::ConfigurationError) + end +end + +RSpec.describe Conductor::Agents::LlmGuardrail do + it 'stores model, policy and max_tokens and evaluates on the server' do + g = described_class.new('openai/gpt-4o-mini', 'No medical advice', name: 'safety', max_tokens: 100) + expect(g.guardrail_type).to eq('llm') + expect(g.model).to eq('openai/gpt-4o-mini') + expect(g.check('anything').passed?).to be false + expect { described_class.new('gpt-4o', 'p') }.to raise_error(Conductor::Agents::ConfigurationError) + end +end diff --git a/spec/conductor/agents/handoff_spec.rb b/spec/conductor/agents/handoff_spec.rb new file mode 100644 index 0000000..1b896d5 --- /dev/null +++ b/spec/conductor/agents/handoff_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'conductor/agents' + +RSpec.describe Conductor::Agents::Handoff do + h = described_class + + it 'accepts an Agent or a name as target' do + agent = Conductor::Agents::Agent.new(name: 'filer', model: 'openai/gpt-4o') + expect(h::OnTextMention.new(target: agent, text: 'x').target).to eq('filer') + expect(h::OnTextMention.new(target: 'filer', text: 'x').target).to eq('filer') + expect { h::OnTextMention.new(target: '', text: 'x') }.to raise_error(Conductor::Agents::ConfigurationError) + end + + it 'OnTextMention matches case-insensitively' do + cond = h::OnTextMention.new(target: 'filer', text: 'ACTIONABLE') + expect(cond.should_handoff('result' => 'This is actionable')).to be true + expect(cond.should_handoff(result: 'nope')).to be false + end + + it 'OnToolResult matches the tool and optional substring' do + cond = h::OnToolResult.new(target: 'refund', tool_name: 'check_order', result_contains: 'broken') + expect(cond.should_handoff('tool_name' => 'check_order', 'tool_result' => 'item broken')).to be true + expect(cond.should_handoff('tool_name' => 'check_order', 'tool_result' => 'fine')).to be false + expect(cond.should_handoff('tool_name' => 'other')).to be false + expect(h::OnToolResult.new(target: 'r', tool_name: 'x').should_handoff('tool_name' => 'x')).to be true + end + + it 'OnCondition calls the block and swallows errors' do + cond = h::OnCondition.new(target: 'summarizer') { |ctx| ctx['iteration'] > 5 } + expect(cond.should_handoff('iteration' => 6)).to be true + expect(cond.should_handoff('iteration' => 1)).to be false + expect(cond.should_handoff({})).to be false + expect { h::OnCondition.new(target: 's') }.to raise_error(Conductor::Agents::ConfigurationError) + end +end diff --git a/spec/conductor/agents/memory_spec.rb b/spec/conductor/agents/memory_spec.rb new file mode 100644 index 0000000..6694ef7 --- /dev/null +++ b/spec/conductor/agents/memory_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'conductor/agents' + +RSpec.describe Conductor::Agents::ConversationMemory do + it 'records messages in the Conductor chat format' do + m = described_class.new + m.add_system_message('sys') + m.add_user_message('hi') + m.add_assistant_message('hello') + m.add_tool_call('get_weather', { 'city' => 'Lisbon' }) + m.add_tool_result('get_weather', { temp: 21 }) + expect(m.messages).to eq([ + { 'role' => 'system', 'message' => 'sys' }, + { 'role' => 'user', 'message' => 'hi' }, + { 'role' => 'assistant', 'message' => 'hello' }, + { 'role' => 'tool_call', 'message' => '', + 'tool_calls' => [{ 'name' => 'get_weather', 'taskReferenceName' => 'get_weather_ref', + 'input' => { 'city' => 'Lisbon' } }] }, + { 'role' => 'tool', 'message' => '{:temp=>21}', 'toolCallId' => 'get_weather_ref', + 'taskReferenceName' => 'get_weather_ref' } + ]) + end + + it 'trims the oldest non-system messages first' do + m = described_class.new(max_messages: 3) + m.add_system_message('sys') + m.add_user_message('one') + m.add_assistant_message('two') + m.add_user_message('three') + expect(m.messages.map { |x| x['message'] }).to eq(%w[sys two three]) + end + + it 'deep copies in to_chat_messages and clears' do + m = described_class.new(messages: [{ role: 'user', message: 'x' }]) + copy = m.to_chat_messages + copy[0]['message'] = 'changed' + expect(m.messages[0]['message']).to eq('x') + m.clear + expect(m).to be_empty + end +end diff --git a/spec/conductor/agents/plans_spec.rb b/spec/conductor/agents/plans_spec.rb new file mode 100644 index 0000000..9f0776b --- /dev/null +++ b/spec/conductor/agents/plans_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'conductor/agents' + +RSpec.describe Conductor::Agents::Plans do + let(:tool) { Conductor::Agents::Tool.new(name: 'factorial', func: ->(n:) { (1..n).reduce(1, :*) }) } + + it 'serializes named slots and makes recovery tools discoverable to the worker runtime' do + agent = Conductor::Agents.plan_execute(name: 'math', tools: [tool], model: 'mock/mockLLM', + planner_instructions: 'Plan it', fallback_instructions: 'Recover', fallback_max_turns: 4, + planner_context: [{ 'type' => 'text', 'text' => 'context' }]) + config = Conductor::Agents::ConfigSerializer.serialize(agent) + expect(config).to include('strategy' => 'plan_execute', 'fallbackMaxTurns' => 4, + 'plannerContext' => [{ 'type' => 'text', 'text' => 'context' }]) + expect(config['planner']).to include('name' => 'math_planner', 'instructions' => 'Plan it') + expect(config['fallback']).to include('name' => 'math_fallback', 'instructions' => 'Recover') + expect(config).not_to have_key('agents') + expect(agent.all_agents.map(&:name)).to eq(%w[math math_planner math_fallback]) + registry = Conductor::Agents::ToolRegistry.new(Conductor::Agents::AgentConfig.new) + expect(registry.tool_workers(agent).map(&:task_definition_name)).to eq(['factorial']) + end + + it 'omits optional fields when there is no recovery agent' do + agent = Conductor::Agents.plan_execute(name: 'math', tools: [tool], model: 'mock/mockLLM') + expect(Conductor::Agents::ConfigSerializer.serialize(agent).keys).not_to include('fallback', 'fallbackMaxTurns', 'plannerContext') + end + + it 'inherits a model from the planner and includes stateful named children' do + planner = Conductor::Agents::Agent.new(name: 'planner', model: 'mock/mockLLM', stateful: true) + agent = Conductor::Agents::Agent.new(name: 'math', strategy: :plan_execute, planner: planner, tools: [tool]) + expect(Conductor::Agents::ConfigSerializer.serialize(agent)['model']).to eq('mock/mockLLM') + expect(agent.stateful_tree?).to be true + end + + it 'rejects a missing planner and invalid named slots' do + expect { Conductor::Agents::Agent.new(name: 'math', strategy: :plan_execute) }.to raise_error(Conductor::Agents::ConfigurationError, /requires planner/) + expect { Conductor::Agents::Agent.new(name: 'math', planner: true) }.to raise_error(Conductor::Agents::ConfigurationError, /must be Agents/) + expect { Conductor::Agents::Agent.new(name: 'math', fallback: 'fallback') }.to raise_error(Conductor::Agents::ConfigurationError, /must be Agents/) + end +end diff --git a/spec/conductor/agents/sse_client_spec.rb b/spec/conductor/agents/sse_client_spec.rb new file mode 100644 index 0000000..8e14fb8 --- /dev/null +++ b/spec/conductor/agents/sse_client_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'webmock/rspec' +require 'conductor/agents' + +RSpec.describe Conductor::Agents::SseClient do + let(:base) { 'http://localhost:8080/api' } + let(:configuration) { Conductor::Configuration.new(server_api_url: base) } + let(:api_client) { Conductor::Http::ApiClient.new(configuration: configuration) } + let(:client) { described_class.new(api_client, logger: Logger.new(nil)) } + let(:recorded) do + ":connected\n\n" \ + "id:1\nevent:thinking\ndata:{\"id\":1,\"type\":\"thinking\",\"executionId\":\"EXEC_1\",\"content\":\"weather_llm__1\",\"timestamp\":0}\n\n" \ + "id:2\nevent:tool_call\ndata:{\"id\":2,\"type\":\"tool_call\",\"executionId\":\"EXEC_1\",\"toolName\":\"get_weather\",\"args\":{\"city\":\"Lisbon\"},\"timestamp\":0}\n\n" \ + "id:3\nevent:done\ndata:{\"id\":3,\"type\":\"done\",\"executionId\":\"EXEC_1\",\"output\":{\"result\":\"Sunny\",\"finishReason\":\"STOP\"},\"timestamp\":0}\n\n" + end + + before { WebMock.enable! } + after { WebMock.reset! } + + describe described_class::Parser do + it 'parses frames split across chunks, comments, integer ids and multi-line data' do + parser = described_class.new + events = [] + parser.feed(":connected\n\nid:7\nev") { |e| events << e } + parser.feed("ent:message\ndata:{\"a\":\n") { |e| events << e } + parser.feed("data:1}\n\n") { |e| events << e } + expect(events).to eq([{ 'heartbeat' => true }, + { 'event' => 'message', 'id' => 7, 'data' => { 'a' => 1 } }]) + end + + it 'wraps non-JSON data as content and infers the event from data.type' do + parser = described_class.new + events = [] + parser.feed("data:plain text\n\ndata:{\"type\":\"done\"}\n\n") { |e| events << e } + expect(events[0]).to eq('event' => nil, 'id' => nil, 'data' => { 'content' => 'plain text' }) + expect(events[1]['event']).to eq('done') + end + end + + it 'streams the recorded events, drops heartbeats and stops after done' do + stub_request(:get, "#{base}/agent/stream/EXEC_1") + .with(headers: { 'Accept' => 'text/event-stream' }) + .to_return(status: 200, headers: { 'Content-Type' => 'text/event-stream' }, body: recorded) + + events = client.each_event('EXEC_1').to_a + expect(events.map { |e| e['event'] }).to eq(%w[thinking tool_call done]) + expect(events.map { |e| e['id'] }).to eq([1, 2, 3]) + expect(events.last['data']['output']['result']).to eq('Sunny') + end + + it 'raises SseUnavailableError when the first connection is refused or non-200' do + stub_request(:get, "#{base}/agent/stream/E500").to_return(status: 500) + expect { client.each_event('E500').to_a }.to raise_error(Conductor::Agents::SseUnavailableError, /500/) + + stub_request(:get, "#{base}/agent/stream/EDOWN").to_raise(Errno::ECONNREFUSED) + expect { client.each_event('EDOWN').to_a }.to raise_error(Conductor::Agents::SseUnavailableError) + end + + it 'reconnects with Last-Event-ID after the stream drops before done' do + stub_const('Conductor::Agents::SseClient::RECONNECT_DELAY', 0) + first = "id:1\nevent:thinking\ndata:{\"content\":\"x\"}\n\n" + rest = "id:2\nevent:done\ndata:{\"output\":{\"result\":\"ok\"}}\n\n" + stub_request(:get, "#{base}/agent/stream/EXEC_2").with { |req| req.headers['Last-Event-Id'].nil? } + .to_return(status: 200, body: first) + resumed = stub_request(:get, "#{base}/agent/stream/EXEC_2").with(headers: { 'Last-Event-ID' => '1' }) + .to_return(status: 200, body: rest) + + events = client.each_event('EXEC_2').to_a + expect(events.map { |e| e['event'] }).to eq(%w[thinking done]) + expect(resumed).to have_been_requested + end + + it 'raises SseUnavailableError when only heartbeats arrive' do + stub_const('Conductor::Agents::SseClient::HEARTBEAT_ONLY_TIMEOUT', -1) + stub_request(:get, "#{base}/agent/stream/EXEC_3").to_return(status: 200, body: ":heartbeat\n:heartbeat\n") + expect { client.each_event('EXEC_3').to_a }.to raise_error(Conductor::Agents::SseUnavailableError, /heartbeats/) + end + + it 'sends the auth header when authentication is configured' do + configuration.authentication_settings = Conductor::AuthenticationSettings.new(key_id: 'k', key_secret: 's') + configuration.update_token('jwt-token') + stub = stub_request(:get, "#{base}/agent/stream/EXEC_4").with(headers: { 'X-Authorization' => 'jwt-token' }) + .to_return(status: 200, body: "event:done\ndata:{}\n\n") + client.each_event('EXEC_4').to_a + expect(stub).to have_been_requested + end +end diff --git a/spec/conductor/agents/status_poller_spec.rb b/spec/conductor/agents/status_poller_spec.rb new file mode 100644 index 0000000..b7b0d6a --- /dev/null +++ b/spec/conductor/agents/status_poller_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'conductor/agents' + +RSpec.describe Conductor::Agents::StatusPoller do + let(:client) { instance_double(Conductor::Client::AgentClient) } + let(:poller) { described_class.new(client, interval: 0) } + + it 'emits waiting once, then done' do + allow(client).to receive(:get_status).and_return( + { 'executionId' => 'E', 'status' => 'RUNNING', 'isComplete' => false, 'isWaiting' => false }, + { 'executionId' => 'E', 'status' => 'RUNNING', 'isComplete' => false, 'isWaiting' => true, 'pendingTool' => { 'x' => 1 } }, + { 'executionId' => 'E', 'status' => 'RUNNING', 'isComplete' => false, 'isWaiting' => true, 'pendingTool' => { 'x' => 1 } }, + { 'executionId' => 'E', 'status' => 'COMPLETED', 'isComplete' => true, 'output' => { 'result' => 'ok' } } + ) + events = poller.each_event('E').to_a + expect(events.map { |e| e['event'] }).to eq(%w[waiting done]) + expect(events[0]['data']['pendingTool']).to eq('x' => 1) + expect(events[1]['data']['output']).to eq('result' => 'ok') + end + + it 'emits error for non-completed terminal statuses' do + allow(client).to receive(:get_status).and_return( + 'executionId' => 'E', 'status' => 'FAILED', 'isComplete' => true, 'reasonForIncompletion' => 'bad' + ) + event = poller.each_event('E').first + expect(event['event']).to eq('error') + expect(event['data']['content']).to eq('bad') + expect(event['data']['status']).to eq('FAILED') + end +end diff --git a/spec/conductor/agents/system_workers_spec.rb b/spec/conductor/agents/system_workers_spec.rb new file mode 100644 index 0000000..324d946 --- /dev/null +++ b/spec/conductor/agents/system_workers_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'conductor/agents' + +RSpec.describe Conductor::Agents::SystemWorkers do + a = Conductor::Agents + + def task(input) + Conductor::Http::Models::Task.from_hash('taskId' => 't', 'inputData' => input) + end + + it 'termination returns should_continue and reason' do + body = described_class.termination(a::Termination::TextMention.new('DONE')) + expect(body.call(task('result' => 'all DONE', 'iteration' => 1))).to eq('should_continue' => false, + 'reason' => "Text 'DONE' found in output") + expect(body.call(task('result' => 'working'))['should_continue']).to be true + end + + it 'termination keeps going when the condition raises' do + cond = a::Termination::TextMention.new('x') + allow(cond).to receive(:should_terminate).and_raise('boom') + expect(described_class.termination(cond, logger: Logger.new(nil)).call(task({}))).to eq('should_continue' => true, 'reason' => '') + end + + it 'guardrail passes, fails with retry, downgrades to raise when retries are exhausted or fix has no output' do + retry_g = a::Guardrail.new(name: 'g', on_fail: :retry, max_retries: 2) { |c| c.include?('ok') } + body = described_class.guardrail(retry_g) + expect(body.call(task('content' => 'ok'))).to eq(described_class.pass_result) + expect(body.call(task('content' => 'bad', 'iteration' => 1))).to include('passed' => false, 'on_fail' => 'retry', + 'guardrail_name' => 'g', 'should_continue' => true) + expect(body.call(task('content' => 'bad', 'iteration' => 2))).to include('on_fail' => 'raise', 'should_continue' => false) + + fix_g = a::Guardrail.new(name: 'f', on_fail: :fix) { |_c| a::GuardrailResult.new(passed: false, message: 'm') } + expect(described_class.guardrail(fix_g).call(task('content' => 'x'))).to include('on_fail' => 'raise', 'fixed_output' => nil) + fixer = a::Guardrail.new(name: 'f2', on_fail: :fix) { |_c| a::GuardrailResult.new(passed: false, fixed_output: 'clean') } + expect(described_class.guardrail(fixer).call(task('content' => { 'a' => 1 }))).to include('on_fail' => 'fix', 'fixed_output' => 'clean') + end + + it 'guardrail reports its own exceptions as failures' do + g = a::Guardrail.new(name: 'g', on_fail: :raise) { |_c| raise 'oops' } + expect(described_class.guardrail(g, logger: Logger.new(nil)).call(task('content' => 'x'))).to include('passed' => false, + 'message' => 'Guardrail error: oops') + end + + it 'callback passes messages / llm_result and returns the chain result' do + chain = ->(**kw) { { 'seen' => kw.keys.map(&:to_s) } } + expect(described_class.callback(chain).call(task('messages' => [], 'llm_result' => 'x'))).to eq('seen' => %w[messages llm_result]) + expect(described_class.callback(->(**) { raise 'x' }, logger: Logger.new(nil)).call(task({}))).to eq({}) + end + + it 'handoff evaluates the condition' do + h = a::Handoff::OnCondition.new(target: 'filer') { |ctx| ctx['result'].to_s.include?('go') } + expect(described_class.handoff(h).call(task('result' => 'go'))).to eq('handoff' => true, 'target' => 'filer') + expect(described_class.handoff(h).call(task('result' => 'stay'))).to eq('handoff' => false, 'target' => 'filer') + end +end diff --git a/spec/conductor/agents/termination_spec.rb b/spec/conductor/agents/termination_spec.rb new file mode 100644 index 0000000..b7d8b90 --- /dev/null +++ b/spec/conductor/agents/termination_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'conductor/agents' + +RSpec.describe Conductor::Agents::Termination do + t = described_class + + it 'TextMention matches case-insensitively by default' do + cond = t::TextMention.new('DONE') + expect(cond.should_terminate('result' => 'all done').should_terminate).to be true + expect(t::TextMention.new('DONE', case_sensitive: true).should_terminate(result: 'all done').should_terminate).to be false + expect(cond.should_terminate('result' => 'working').should_terminate).to be false + expect { t::TextMention.new('') }.to raise_error(Conductor::Agents::ConfigurationError) + end + + it 'StopMessage needs an exact stripped match' do + cond = t::StopMessage.new + expect(cond.should_terminate('result' => " TERMINATE \n").should_terminate).to be true + expect(cond.should_terminate('result' => 'TERMINATE now').should_terminate).to be false + end + + it 'MaxMessage counts messages or falls back to iteration' do + cond = t::MaxMessage.new(2) + expect(cond.should_terminate('messages' => [1, 2]).should_terminate).to be true + expect(cond.should_terminate('messages' => [], 'iteration' => 1).should_terminate).to be false + expect(cond.should_terminate('iteration' => 5).reason).to include('5') + expect { t::MaxMessage.new(0) }.to raise_error(Conductor::Agents::ConfigurationError) + end + + it 'TokenUsage checks each configured limit' do + cond = t::TokenUsage.new(max_total_tokens: 100) + expect(cond.should_terminate('token_usage' => { 'total_tokens' => 100 }).should_terminate).to be true + expect(cond.should_terminate('token_usage' => { 'totalTokens' => 10 }).should_terminate).to be false + expect(cond.should_terminate({}).should_terminate).to be false + expect { t::TokenUsage.new }.to raise_error(Conductor::Agents::ConfigurationError) + end + + it 'combines with & and | and flattens same-type children' do + a = t::TextMention.new('A') + b = t::MaxMessage.new(3) + c = t::StopMessage.new('X') + both = a & b & c + expect(both).to be_a(t::And) + expect(both.conditions.size).to eq(3) + expect(both.should_terminate('result' => 'A X', 'iteration' => 3).should_terminate).to be false + expect(both.should_terminate('result' => 'A', 'iteration' => 3).should_terminate).to be false + + either = a | b | c + expect(either).to be_a(t::Or) + expect(either.conditions.size).to eq(3) + expect(either.should_terminate('result' => 'X').reason).to include('X') + expect(either.should_terminate('result' => 'nothing').should_terminate).to be false + + nested = c | (a & b) + expect(nested.conditions.size).to eq(2) + expect(nested.conditions.last).to be_a(t::And) + end +end diff --git a/spec/conductor/agents/tool_registry_spec.rb b/spec/conductor/agents/tool_registry_spec.rb new file mode 100644 index 0000000..2d76366 --- /dev/null +++ b/spec/conductor/agents/tool_registry_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'support/agent_tools' + +RSpec.describe Conductor::Agents::ToolRegistry do + a = Conductor::Agents + let(:config) { a::AgentConfig.new(worker_poll_interval_ms: 50, worker_thread_count: 2) } + let(:logger) { instance_double(Logger, warn: nil, info: nil, error: nil, debug: nil) } + let(:registry) { described_class.new(config, logger: logger) } + let(:weather) do + agent = a::Agent.new(name: 'weather', model: 'openai/gpt-4o-mini', instructions: 'Answer weather questions.') + agent.add_tool :current + agent.add_tool a::Tool.http('fetch', 'http://x') + agent + end + + it 'builds one worker per local tool with the Python task definition defaults' do + workers = registry.workers_for(weather, required_workers: ['current']) + expect(workers.map(&:task_definition_name)).to eq(['current']) + w = workers.first + expect(w.register_task_def).to be true + expect(w.overwrite_task_def).to be true + expect(w.lease_extend_enabled).to be true + expect(w.poll_interval).to eq(50) + expect(w.thread_count).to eq(2) + expect(w.domain).to be_nil + td = w.task_def_template.to_h + expect(td).to include('name' => 'current', 'retryCount' => 2, 'timeoutSeconds' => 0, 'timeoutPolicy' => 'RETRY', + 'retryLogic' => 'LINEAR_BACKOFF', 'retryDelaySeconds' => 2, 'responseTimeoutSeconds' => 10, + 'enforceSchema' => false, 'runtimeMetadata' => []) + end + + it 'puts tool and agent credentials on runtimeMetadata' do + filer = a::Agent.new(name: 'filer', model: 'm/x', credentials: ['ORG_KEY']) + filer.add_tool :create_issue + td = registry.workers_for(filer).first.task_def_template + expect(td.runtime_metadata).to eq(%w[GH_TOKEN ORG_KEY]) + end + + it 'runs the tool through Dispatch' do + worker = registry.workers_for(weather).first + task = Conductor::Http::Models::Task.from_hash('taskId' => 't', 'inputData' => { 'city' => 'Lisbon', 'method' => 'current' }) + result = worker.execute(task) + expect(result.status).to eq('COMPLETED') + expect(result.output_data['temp_c']).to eq(21.0) + end + + it 'inherits credentials through teams and combines them for shared tools' do + first = a::Agent.new(name: 'first', model: 'm/x', tools: [SpecTools::Weather[:current]], credentials: ['FIRST']) + second = a::Agent.new(name: 'second', model: 'm/x', tools: [SpecTools::Weather[:current]], credentials: ['SECOND']) + team = a::Agent.new(name: 'team', agents: [first, second], credentials: ['TEAM']) + workers = registry.tool_workers(team) + expect(workers.size).to eq(1) + expect(workers.first.task_def_template.runtime_metadata).to match_array(%w[TEAM FIRST SECOND]) + end + + it 'serves custom tool guardrails and function routers required by the server' do + guard = a::Guardrail.new(name: 'tool_policy') { |content| content == 'safe' } + tool = a::Tool.new(name: 'action', func: -> { {} }, guardrails: [guard]) + child = a::Agent.new(name: 'child', model: 'm/x', tools: [tool]) + team = a::Agent.new(name: 'team', agents: [child], strategy: :router, router: ->(prompt) { "#{prompt}_route" }) + workers = registry.workers_for(team, required_workers: %w[tool_policy team_router_fn]) + router = workers.find { |worker| worker.task_definition_name == 'team_router_fn' } + task = Conductor::Http::Models::Task.new(input_data: { 'prompt' => 'child' }) + expect(router.execute(task).output_data).to eq('selected_agent' => 'child_route') + policy = workers.find { |worker| worker.task_definition_name == 'tool_policy' } + expect(policy.execute(Conductor::Http::Models::Task.new(input_data: { 'content' => 'safe' })).output_data['passed']).to be true + end + + it 'registers hoisted condition handoffs using the parent name' do + first = a::Agent.new(name: 'first', model: 'm/x') + second = a::Agent.new(name: 'second', model: 'm/x') + first.hands_off_to(second, on: ->(_context) { true }) + team = a::Agent.new(name: 'team', agents: [first, second]) + worker = registry.workers_for(team, required_workers: ['team_handoff_second']).first + expect(worker.task_definition_name).to eq('team_handoff_second') + expect(worker.execute(Conductor::Http::Models::Task.new(input_data: {})).output_data).to include('handoff' => true, 'target' => 'second') + end + + it 'uses the first team member if a router raises, and an empty name without members' do + router = ->(_prompt) { raise 'unavailable' } + task = Conductor::Http::Models::Task.new(input_data: {}) + expect(a::SystemWorkers.router(router, ['first'], logger: logger).call(task)).to eq('selected_agent' => 'first') + expect(a::SystemWorkers.router(router, [], logger: logger).call(task)).to eq('selected_agent' => '') + end + + it 'registers system workers only when the server requires them and warns about unknown names' do + agent = a::Agent.new(name: 'bug_desk', model: 'm/x') + agent.stop_when 'ISSUE_FILED' + agent.add_guardrail a::Guardrail.new(name: 'no_pii') { true } + agent.callback(:before_model) { |**| nil } + agent.add_handoff a::Handoff::OnCondition.new(target: 'filer') { true } + + names = registry.workers_for(agent, required_workers: %w[bug_desk_termination no_pii bug_desk_before_model + bug_desk_handoff_filer mystery_task]).map(&:task_definition_name) + expect(names).to match_array(%w[bug_desk_termination no_pii bug_desk_before_model bug_desk_handoff_filer]) + expect(logger).to have_received(:warn).with(/mystery_task/) + + only_termination = registry.workers_for(agent, required_workers: ['bug_desk_termination']).map(&:task_definition_name) + expect(only_termination).to eq(['bug_desk_termination']) + expect(registry.workers_for(agent, required_workers: nil).size).to eq(4) + end + + it 'collects tools from the whole team once and applies the run domain for stateful trees' do + triage = a::Agent.new(name: 'triage', model: 'm/x') + filer = a::Agent.new(name: 'filer', model: 'm/x', stateful: true) + filer.add_tool :create_issue + triage.add_tool :create_issue + team = a::Agent.new(name: 'team', agents: [triage, filer]) + workers = registry.workers_for(team, domain: 'run-1') + expect(workers.map(&:task_definition_name)).to eq(['create_issue']) + expect(workers.first.domain).to eq('run-1') + end +end diff --git a/spec/conductor/agents/tool_spec.rb b/spec/conductor/agents/tool_spec.rb new file mode 100644 index 0000000..cce5e97 --- /dev/null +++ b/spec/conductor/agents/tool_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'conductor/agents' + +RSpec.describe Conductor::Agents::Tool do + describe '#initialize' do + it 'applies the Python defaults' do + td = described_class.new(name: 't') + expect(td.description).to eq('') + expect(td.input_schema).to eq({}) + expect(td.tool_type).to eq('worker') + expect(td.approval_required).to be false + expect(td.retry_count).to eq(2) + expect(td.retry_delay_seconds).to eq(2) + expect(td.retry_policy).to eq('linear_backoff') + expect(td.retry_logic).to eq('LINEAR_BACKOFF') + expect(td.credentials).to eq([]) + expect(td.server_side?).to be true + end + + it 'rejects unknown tool types and retry policies' do + expect { described_class.new(name: 't', tool_type: 'magic') }.to raise_error(Conductor::Agents::ConfigurationError) + expect { described_class.new(name: 't', retry_policy: 'never') }.to raise_error(Conductor::Agents::ConfigurationError) + expect { described_class.new(name: '') }.to raise_error(Conductor::Agents::ConfigurationError) + end + + it 'is local only for worker/cli tools with a func' do + expect(described_class.new(name: 't', func: -> {}).local?).to be true + expect(described_class.new(name: 't', func: -> {}, tool_type: 'http').local?).to be false + expect(described_class.new(name: 't').local?).to be false + end + end + + describe '#with_guardrails' do + it 'returns a guarded copy and leaves the original untouched' do + guard = Conductor::Agents::Guardrail.new(name: 'g') { true } + original = described_class.new(name: 't', func: -> {}) + guarded = original.with_guardrails(guard) + expect(guarded).not_to equal(original) + expect(guarded.guardrails).to eq([guard]) + expect(guarded.name).to eq('t') + expect(guarded.local?).to be true + expect(original.guardrails).to eq([]) + end + + it 'replaces existing guardrails and flattens lists' do + old = Conductor::Agents::Guardrail.new(name: 'old') { true } + new1 = Conductor::Agents::Guardrail.new(name: 'new1') { true } + new2 = Conductor::Agents::Guardrail.new(name: 'new2') { true } + tool = described_class.new(name: 't', guardrails: [old]) + expect(tool.with_guardrails([new1, new2]).guardrails).to eq([new1, new2]) + end + end + + describe '#call' do + it 'builds a prefilled tool call' do + call = described_class.new(name: 'get_weather').call(city: 'Lisbon') + expect(call.to_h).to eq('toolName' => 'get_weather', 'arguments' => { 'city' => 'Lisbon' }) + end + end + + describe '.http' do + it 'serializes the Python config keys' do + td = described_class.http('fetch', 'https://x.test/a', description: 'Fetch', method: 'post', + headers: { 'Authorization' => 'Bearer ${API_KEY}' }, credentials: ['API_KEY']) + expect(td.tool_type).to eq('http') + expect(td.config).to eq('url' => 'https://x.test/a', 'method' => 'POST', + 'headers' => { 'Authorization' => 'Bearer ${API_KEY}' }, + 'accept' => ['application/json'], 'contentType' => 'application/json') + expect(td.credentials).to eq(['API_KEY']) + expect(td.input_schema).to eq('type' => 'object', 'properties' => {}) + end + + it 'rejects undeclared ${NAME} placeholders' do + expect do + described_class.http('fetch', 'https://x', headers: { 'X' => '${SECRET}' }) + end.to raise_error(Conductor::Agents::ConfigurationError, /SECRET/) + end + end + + describe '.mcp' do + it 'defaults the name and carries server_url / max_tools' do + td = described_class.mcp('http://mcp:3001/mcp', tool_names: %w[a b]) + expect(td.name).to eq('mcp_tools') + expect(td.tool_type).to eq('mcp') + expect(td.config).to eq('server_url' => 'http://mcp:3001/mcp', 'tool_names' => %w[a b], 'max_tools' => 64) + end + end + + describe '.human' do + it 'uses a question schema by default' do + td = described_class.human('ask_user', description: 'Ask the user.') + expect(td.tool_type).to eq('human') + expect(td.input_schema['required']).to eq(['question']) + end + end + + describe '.agent' do + it 'wraps an agent with the request schema and retry config' do + agent = Conductor::Agents::Agent.new(name: 'researcher', model: 'openai/gpt-4o') + td = described_class.agent(agent, retry_count: 0, optional: false) + expect(td.name).to eq('researcher') + expect(td.description).to eq('Invoke the researcher agent') + expect(td.tool_type).to eq('agent_tool') + expect(td.config).to eq('agent' => agent, 'retryCount' => 0, 'optional' => false) + expect(td.input_schema['required']).to eq(['request']) + end + end + + describe 'media, rag and message factories' do + it 'set the task type in config' do + expect(described_class.image('img', description: 'd', llm_provider: 'openai', model: 'dall-e-3').config['taskType']).to eq('GENERATE_IMAGE') + expect(described_class.pdf.config).to eq('taskType' => 'GENERATE_PDF') + idx = described_class.index('idx', description: 'd', vector_db: 'pinecone', index: 'docs', + embedding_model_provider: 'openai', embedding_model: 'e', chunk_size: 100) + expect(idx.config).to include('taskType' => 'LLM_INDEX_TEXT', 'chunkSize' => 100, 'namespace' => 'default_ns') + search = described_class.search('s', description: 'd', vector_db: 'pinecone', index: 'docs', + embedding_model_provider: 'openai', embedding_model: 'e') + expect(search.config).to include('taskType' => 'LLM_SEARCH_INDEX', 'maxResults' => 5) + expect(described_class.wait_for_message('w', description: 'd', blocking: false).config).to eq('batchSize' => 1, 'blocking' => false) + expect(described_class.wait_for_message('w', description: 'd').config).to eq('batchSize' => 1) + end + end +end diff --git a/spec/conductor/agents/tools_spec.rb b/spec/conductor/agents/tools_spec.rb new file mode 100644 index 0000000..3941733 --- /dev/null +++ b/spec/conductor/agents/tools_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'support/agent_tools' + +RSpec.describe Conductor::Agents::Tools do + let(:weather) { SpecTools::Weather } + + describe 'tool def' do + it 'names the tool after the method and humanizes the description' do + td = weather[:current] + expect(td).to be_a(Conductor::Agents::Tool) + expect(td.name).to eq('current') + expect(td.description).to eq('Current') + expect(td.local?).to be true + end + + it 'registers the tool globally so Agent#add_tool(:name) finds it' do + expect(described_class.lookup(:current)).to equal(weather[:current]) + end + + it 'types required parameters from class defaults and optional ones from literals' do + expect(weather[:current].input_schema).to eq( + 'type' => 'object', + 'properties' => { + 'city' => { 'type' => 'string' }, + 'units' => { 'type' => 'string', 'default' => 'metric' } + }, + 'required' => ['city'] + ) + end + + it 'handles integers, booleans, floats, typed arrays, enums, nil and hash defaults' do + schema = weather[:forecast].input_schema + expect(schema['required']).to eq(%w[city tags]) + expect(schema['properties']).to eq( + 'city' => { 'type' => 'string' }, + 'days' => { 'type' => 'integer', 'default' => 3 }, + 'detailed' => { 'type' => 'boolean', 'default' => false }, + 'tags' => { 'type' => 'array', 'items' => { 'type' => 'string' } }, + 'mode' => { 'type' => 'string', 'enum' => %w[brief full], 'default' => 'brief' }, + 'ratio' => { 'type' => 'number', 'default' => 0.5 }, + 'extra' => {}, + 'opts' => { 'type' => 'object', 'default' => {} } + ) + end + + it 'defaults the output schema to an object' do + expect(weather[:current].output_schema).to eq('type' => 'object', 'additionalProperties' => {}) + end + + it 'keeps the method callable as a normal method' do + expect(weather.current(city: 'Lisbon')).to include(temp_c: 21.0) + expect(weather[:current].func.call(city: 'Porto', units: 'imperial')[:summary]).to eq('Sunny in Porto (imperial)') + end + + it 'rejects positional parameters' do + expect { SpecTools::Refunds.tool(:positional) }.to raise_error(Conductor::Agents::ConfigurationError, /keyword/) + end + + it 'raises for unknown methods and unknown options' do + expect { weather.tool(:nope) }.to raise_error(Conductor::Agents::ConfigurationError, /no such method/) + expect { weather.tool(:current, colour: 'red') }.to raise_error(Conductor::Agents::ConfigurationError, /unknown tool option/) + end + + it 'accepts a tool name that differs from the method name' do + scope = Module.new do + extend Conductor::Agents::Tools + def fetch_weather(city: String) = city + tool :fetch_weather, name: 'get_weather' + end + expect(scope[:get_weather].name).to eq('get_weather') + expect(scope.tool_defs.map(&:name)).to eq(['get_weather']) + expect(described_class.lookup('get_weather')).to equal(scope[:get_weather]) + end + + it 'attaches guardrails given at definition time' do + guard = Conductor::Agents::Guardrail.new(name: 'tool_policy') { |content| content == 'safe' } + scope = Module.new do + extend Conductor::Agents::Tools + def guarded(city: String) = city + end + scope.tool(:guarded, guardrails: [guard]) + expect(scope[:guarded].guardrails).to eq([guard]) + end + + it 'treats keywords without defaults as required untyped properties' do + expect(SpecTools::Plain[:lookup].input_schema).to eq( + 'type' => 'object', + 'properties' => { 'city' => {}, 'units' => { 'type' => 'string', 'default' => 'metric' } }, + 'required' => ['city'] + ) + end + + it 'falls back to untyped properties when the AST is unavailable' do + allow(Conductor::Agents::Tools::SchemaBuilder).to receive(:ast_of).and_return(nil) + schema = Conductor::Agents::Tools::SchemaBuilder.input_schema(SpecTools::Plain.method(:lookup)) + expect(schema).to eq('type' => 'object', 'properties' => { 'city' => {}, 'units' => {} }, 'required' => ['city']) + end + end + + describe 'describe / requires_approval / tool_credentials' do + it 'overrides the description' do + expect(weather[:forecast].description).to eq('Multi-day forecast.') + end + + it 'marks approval' do + expect(SpecTools::Refunds[:issue_refund].approval_required).to be true + expect(weather[:current].approval_required).to be false + end + + it 'adds explicit credentials' do + SpecTools::Github.tool_credentials(:dynamic_secret, 'DYN_KEY') + expect(SpecTools::Github[:dynamic_secret].credentials).to eq(['DYN_KEY']) + end + end + + describe 'secret scanning' do + it 'declares literal secret() names' do + expect(SpecTools::Github[:create_issue].credentials).to eq(['GH_TOKEN']) + end + + it 'declares every literal in secrets_env()' do + expect(SpecTools::Github[:gh_cli].credentials).to eq(%w[GH_TOKEN GH_HOST]) + end + + it 'ignores dynamic names' do + expect(SpecTools::Github[:dynamic_secret].credentials).not_to include('name') + end + end + + describe '#tool_defs' do + it 'lists the tools of a module in definition order' do + expect(weather.tool_defs.map(&:name)).to eq(%w[current forecast]) + end + end +end diff --git a/spec/conductor/client/agent_client_spec.rb b/spec/conductor/client/agent_client_spec.rb new file mode 100644 index 0000000..e86874d --- /dev/null +++ b/spec/conductor/client/agent_client_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Conductor::Client::AgentClient do + let(:api_client) { instance_double(Conductor::Http::ApiClient) } + let(:agent_api) { instance_double(Conductor::Http::Api::AgentResourceApi) } + let(:client) { described_class.new(api_client) } + + before do + allow(Conductor::Http::Api::AgentResourceApi).to receive(:new).with(api_client).and_return(agent_api) + end + + it 'delegates start/deploy/compile' do + expect(agent_api).to receive(:start).with({ 'prompt' => 'x' }).and_return({ 'executionId' => 'E' }) + expect(agent_api).to receive(:deploy).with({ 'agentConfig' => {} }) + expect(agent_api).to receive(:compile).with({ 'agentConfig' => {} }) + + expect(client.start_agent('prompt' => 'x')).to eq('executionId' => 'E') + client.deploy_agent('agentConfig' => {}) + client.compile_agent('agentConfig' => {}) + end + + it 'delegates status, execution and list' do + expect(agent_api).to receive(:status).with('E') + expect(agent_api).to receive(:execution).with('E') + expect(agent_api).to receive(:executions).with({ size: 1 }) + client.get_status('E') + client.get_execution('E') + client.list_executions(size: 1) + end + + it 'streams with the existing authenticated transport and reconnect cursor' do + require 'conductor/agents' + stream = instance_double(Conductor::Agents::SseClient) + event = { 'event' => 'done', 'data' => {} } + expect(Conductor::Agents::SseClient).to receive(:new).with(api_client).and_return(stream) + expect(stream).to receive(:each_event).with('E', last_event_id: 7).and_yield(event) + received = [] + client.stream_sse('E', last_event_id: 7) { |value| received << value } + expect(received).to eq([event]) + end + + it 'builds approval bodies like the Python client' do + expect(agent_api).to receive(:respond).with('E', { 'approved' => true }) + expect(agent_api).to receive(:respond).with('E', { 'approved' => false, 'reason' => 'Needs a manager' }) + expect(agent_api).to receive(:respond).with('E', { 'message' => 'hello' }) + expect(agent_api).to receive(:respond).with('E', { 'output' => 42 }) + + client.approve('E') + client.reject('E', 'Needs a manager') + client.send_message('E', 'hello') + client.respond('E', 42) + end + + it 'delegates control operations' do + expect(agent_api).to receive(:stop).with('E') + expect(agent_api).to receive(:signal).with('E', 'go') + expect(agent_api).to receive(:pause).with('E') + expect(agent_api).to receive(:resume).with('E') + expect(agent_api).to receive(:cancel).with('E', reason: 'r') + client.stop('E') + client.signal('E', 'go') + client.pause('E') + client.resume('E') + client.cancel('E', reason: 'r') + end + + it 'maps 404 to AgentNotFoundError and other errors to AgentApiError with the server message' do + allow(agent_api).to receive(:status).and_raise(Conductor::ApiError.new('nf', status: 404)) + expect { client.get_status('E') }.to raise_error(Conductor::AgentNotFoundError) + + body = { error: 'agentConfig.model is required', status: 400 }.to_json + allow(agent_api).to receive(:start).and_raise(Conductor::ApiError.new('bad', status: 400, body: body)) + expect { client.start_agent({}) }.to raise_error(Conductor::AgentApiError) { |e| + expect(e.status).to eq(400) + expect(e.error).to eq('agentConfig.model is required') + } + end +end diff --git a/spec/conductor/configuration/token_cache_spec.rb b/spec/conductor/configuration/token_cache_spec.rb new file mode 100644 index 0000000..42ad3e6 --- /dev/null +++ b/spec/conductor/configuration/token_cache_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Conductor::Configuration, '#auth_token' do + it 'starts with no token and a zero update time' do + config = described_class.new(server_api_url: 'http://a/api') + expect(config.auth_token).to be_nil + expect(config.token_update_time).to eq(0) + end + + it 'caches the token per instance' do + a = described_class.new(server_api_url: 'http://a/api') + b = described_class.new(server_api_url: 'http://b/api') + + a.update_token('token-a') + + expect(a.auth_token).to eq('token-a') + expect(a.token_update_time).to be > 0 + expect(b.auth_token).to be_nil + expect(b.token_update_time).to eq(0) + end + + it 'keeps the deprecated class-level accessors working with a warning' do + expect { described_class.auth_token = 'legacy' }.to output(/deprecated/).to_stderr + expect(described_class.auth_token).to eq('legacy') + described_class.auth_token = nil + end +end diff --git a/spec/conductor/http/api/agent_resource_api_spec.rb b/spec/conductor/http/api/agent_resource_api_spec.rb new file mode 100644 index 0000000..fb7e000 --- /dev/null +++ b/spec/conductor/http/api/agent_resource_api_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'webmock/rspec' + +RSpec.describe Conductor::Http::Api::AgentResourceApi do + let(:base) { 'http://localhost:8080/api' } + let(:configuration) { Conductor::Configuration.new(server_api_url: base) } + let(:api_client) { Conductor::Http::ApiClient.new(configuration: configuration) } + let(:api) { described_class.new(api_client) } + + before { WebMock.enable! } + after { WebMock.reset! } + + it 'POSTs the start request and returns the parsed body' do + stub = stub_request(:post, "#{base}/agent/start") + .with(body: hash_including('prompt' => 'hi', 'agentConfig' => hash_including('name' => 'a'))) + .to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: { executionId: 'EXEC_1', agentName: 'a', requiredWorkers: ['get_weather'] }.to_json) + + result = api.start('agentConfig' => { 'name' => 'a' }, 'prompt' => 'hi') + + expect(stub).to have_been_requested + expect(result).to eq('executionId' => 'EXEC_1', 'agentName' => 'a', 'requiredWorkers' => ['get_weather']) + end + + it 'POSTs deploy and compile' do + stub_request(:post, "#{base}/agent/deploy").to_return(body: { agentName: 'a' }.to_json, + headers: { 'Content-Type' => 'application/json' }) + stub_request(:post, "#{base}/agent/compile").to_return(body: { workflowDef: {}, requiredWorkers: [] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + expect(api.deploy('agentConfig' => {})).to eq('agentName' => 'a') + expect(api.compile('agentConfig' => {})).to include('workflowDef') + end + + it 'GETs status and execution' do + stub_request(:get, "#{base}/agent/EXEC_1/status") + .to_return(body: { executionId: 'EXEC_1', isComplete: true, isWaiting: false }.to_json, + headers: { 'Content-Type' => 'application/json' }) + stub_request(:get, "#{base}/agent/execution/EXEC_1") + .to_return(body: { tokenUsage: { totalTokens: 5 } }.to_json, headers: { 'Content-Type' => 'application/json' }) + + expect(api.status('EXEC_1')).to include('isComplete' => true) + expect(api.execution('EXEC_1')).to eq('tokenUsage' => { 'totalTokens' => 5 }) + end + + it 'GETs executions with query params' do + stub = stub_request(:get, "#{base}/agent/executions").with(query: { 'size' => '5', 'agentName' => 'a' }) + .to_return(body: { totalHits: 0, results: [] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + expect(api.executions(size: 5, agentName: 'a')).to eq('totalHits' => 0, 'results' => []) + expect(stub).to have_been_requested + end + + it 'POSTs respond, stop and signal with the exact bodies' do + respond = stub_request(:post, "#{base}/agent/EXEC_1/respond").with(body: { approved: false, reason: 'no' }.to_json) + stop = stub_request(:post, "#{base}/agent/EXEC_1/stop") + signal = stub_request(:post, "#{base}/agent/EXEC_1/signal").with(body: { message: 'hurry' }.to_json) + + api.respond('EXEC_1', { 'approved' => false, 'reason' => 'no' }) + api.stop('EXEC_1') + api.signal('EXEC_1', 'hurry') + + expect(respond).to have_been_requested + expect(stop).to have_been_requested + expect(signal).to have_been_requested + end + + it 'PUTs pause/resume and DELETEs cancel with a reason' do + pause = stub_request(:put, "#{base}/agent/EXEC_1/pause") + resume = stub_request(:put, "#{base}/agent/EXEC_1/resume") + cancel = stub_request(:delete, "#{base}/agent/EXEC_1/cancel").with(query: { 'reason' => 'bye' }) + + api.pause('EXEC_1') + api.resume('EXEC_1') + api.cancel('EXEC_1', reason: 'bye') + + expect(pause).to have_been_requested + expect(resume).to have_been_requested + expect(cancel).to have_been_requested + end + + it 'lists, gets and deletes deployed agents' do + stub_request(:get, "#{base}/agent/list").to_return(body: [{ name: 'a' }].to_json, + headers: { 'Content-Type' => 'application/json' }) + stub_request(:get, "#{base}/agent/a").with(query: { 'version' => '2' }) + .to_return(body: { name: 'a' }.to_json, + headers: { 'Content-Type' => 'application/json' }) + del = stub_request(:delete, "#{base}/agent/a") + + expect(api.list).to eq([{ 'name' => 'a' }]) + expect(api.get_agent('a', version: 2)).to eq('name' => 'a') + api.delete('a') + expect(del).to have_been_requested + end +end diff --git a/spec/conductor/models/runtime_metadata_spec.rb b/spec/conductor/models/runtime_metadata_spec.rb new file mode 100644 index 0000000..4ec0067 --- /dev/null +++ b/spec/conductor/models/runtime_metadata_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Conductor::Http::Models::Task, '#runtime_metadata' do + it 'defaults to an empty hash' do + expect(described_class.new.runtime_metadata).to eq({}) + end + + it 'deserializes the wire-only secret map from a poll response' do + task = described_class.from_hash( + 'taskType' => 'create_issue', + 'taskId' => 'TASK_1', + 'inputData' => { 'title' => 'bug' }, + 'runtimeMetadata' => { 'GH_TOKEN' => 'ghp_secret' } + ) + expect(task.runtime_metadata).to eq('GH_TOKEN' => 'ghp_secret') + end +end + +RSpec.describe Conductor::Http::Models::TaskDef, '#runtime_metadata' do + it 'defaults to an empty list and enforce_schema false' do + task_def = described_class.new(name: 't') + expect(task_def.runtime_metadata).to eq([]) + expect(task_def.enforce_schema).to be false + end + + it 'serializes secret names as runtimeMetadata' do + task_def = described_class.new(name: 'create_issue', runtime_metadata: ['GH_TOKEN']) + hash = task_def.to_h + expect(hash['runtimeMetadata']).to eq(['GH_TOKEN']) + expect(hash['enforceSchema']).to be false + end + + it 'keeps agent worker defaults when used as a template (timeout 0 is not overridden)' do + template = described_class.new(name: 'x', timeout_seconds: 0, response_timeout_seconds: 10, + retry_count: 2, retry_delay_seconds: 2, + retry_logic: 'LINEAR_BACKOFF', timeout_policy: 'RETRY', + runtime_metadata: ['GH_TOKEN']) + worker = Conductor::Worker::Worker.new('get_weather', register_task_def: true, + task_def_template: template) { {} } + registrar = Conductor::Worker::TaskDefinitionRegistrar.new(Conductor::Configuration.new, logger: Logger.new(nil)) + task_def = registrar.send(:build_task_definition, worker) + + expect(task_def.name).to eq('get_weather') + expect(task_def.timeout_seconds).to eq(0) + expect(task_def.response_timeout_seconds).to eq(10) + expect(task_def.retry_count).to eq(2) + expect(task_def.retry_logic).to eq('LINEAR_BACKOFF') + expect(task_def.timeout_policy).to eq('RETRY') + expect(task_def.runtime_metadata).to eq(['GH_TOKEN']) + end +end diff --git a/spec/conductor/orkes/orkes_clients_spec.rb b/spec/conductor/orkes/orkes_clients_spec.rb index 31ebeda..69c546d 100644 --- a/spec/conductor/orkes/orkes_clients_spec.rb +++ b/spec/conductor/orkes/orkes_clients_spec.rb @@ -81,6 +81,12 @@ end end + describe '#get_agent_client' do + it 'returns an AgentClient' do + expect(clients.get_agent_client).to be_a(Conductor::Client::AgentClient) + end + end + describe '#get_schema_client' do it 'returns a SchemaClient' do result = clients.get_schema_client diff --git a/spec/conductor/worker/lease_renewer_spec.rb b/spec/conductor/worker/lease_renewer_spec.rb new file mode 100644 index 0000000..f512438 --- /dev/null +++ b/spec/conductor/worker/lease_renewer_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'timeout' +require 'conductor/worker/lease_renewer' + +RSpec.describe Conductor::Worker::LeaseRenewer do + let(:client) { instance_double(Conductor::Client::TaskClient) } + let(:logger) { instance_double(Logger, warn: nil) } + let(:renewer) { described_class.new(task_client: client, logger: logger) } + let(:task) do + Conductor::Http::Models::Task.new(task_id: 'task-1', workflow_instance_id: 'workflow-1', response_timeout_seconds: 0.02) + end + + it 'renews repeatedly while the body runs, returns its value and stops afterward' do + heartbeats = Queue.new + expect(client).not_to receive(:update_task_v2) + allow(client).to receive(:update_task) do |result| + heartbeats << [result, Thread.current] + end + + heartbeat_thread = nil + value = renewer.during(task, worker_id: 'worker-1') do + 2.times do + result, heartbeat_thread = Timeout.timeout(2) { heartbeats.pop } + expect(result).to have_attributes( + task_id: 'task-1', workflow_instance_id: 'workflow-1', worker_id: 'worker-1', + status: 'IN_PROGRESS', extend_lease: true, output_data: {} + ) + end + :finished + end + + expect(value).to eq(:finished) + expect(heartbeat_thread).not_to be_alive + end + + [nil, 0, -1].each do |timeout| + it "does not renew a task with response timeout #{timeout.inspect}" do + task.response_timeout_seconds = timeout + expect(client).not_to receive(:update_task) + expect(renewer.during(task, worker_id: 'worker-1') { :finished }).to eq(:finished) + end + end + + it 'stops renewal when the body raises and preserves the exception' do + heartbeats = Queue.new + allow(client).to receive(:update_task) { heartbeats << Thread.current } + heartbeat_thread = nil + + expect do + renewer.during(task, worker_id: 'worker-1') do + heartbeat_thread = Timeout.timeout(2) { heartbeats.pop } + raise 'tool failed' + end + end.to raise_error(RuntimeError, 'tool failed') + + expect(heartbeat_thread).not_to be_alive + end + + it 'logs a failed renewal and continues renewing without failing the body' do + heartbeats = Queue.new + calls = 0 + allow(client).to receive(:update_task) do + calls += 1 + raise Conductor::ApiError.new('temporarily unavailable', status: 503) if calls == 1 + + heartbeats << true + end + + expect(renewer.during(task, worker_id: 'worker-1') { Timeout.timeout(2) { heartbeats.pop } }).to be true + expect(logger).to have_received(:warn).with(/Lease renewal failed for task task-1/) + end + + it 'drains an in-flight renewal before returning to the caller' do + started = Queue.new + release = Queue.new + body_finished = Queue.new + allow(client).to receive(:update_task) do + started << true + release.pop + end + execution = Thread.new do + renewer.during(task, worker_id: 'worker-1') do + started.pop + body_finished << true + end + :finished + end + + Timeout.timeout(2) { body_finished.pop } + expect(execution.join(0.02)).to be_nil + release << true + expect(Timeout.timeout(2) { execution.value }).to eq(:finished) + ensure + release << true + execution&.join(2) + execution&.kill + end +end diff --git a/spec/conductor/worker/task_definition_registrar_spec.rb b/spec/conductor/worker/task_definition_registrar_spec.rb index 9517f22..ba62d1c 100644 --- a/spec/conductor/worker/task_definition_registrar_spec.rb +++ b/spec/conductor/worker/task_definition_registrar_spec.rb @@ -20,6 +20,18 @@ end describe '#register' do + [true, false].each do |overwrite| + it "creates a flat task definition array when missing (overwrite=#{overwrite})" do + metadata = instance_double(Conductor::Client::MetadataClient) + allow(Conductor::Client::MetadataClient).to receive(:new).and_return(metadata) + method = overwrite ? :update_task_def : :get_task_def + allow(metadata).to receive(method).and_raise(Conductor::ApiError.new('missing', status: 404)) + expect(metadata).to receive(:register_task_def).with(an_instance_of(Conductor::Http::Models::TaskDef)) + worker = Conductor::Worker::Worker.new('new_task', register_task_def: true, overwrite_task_def: overwrite) { {} } + expect(registrar.register(worker)).to be true + end + end + context 'when worker.register_task_def is false' do it 'returns false without registering' do worker = Conductor::Worker::Worker.new('test_task', register_task_def: false) { {} } diff --git a/spec/conductor/worker/task_lease_renewal_spec.rb b/spec/conductor/worker/task_lease_renewal_spec.rb new file mode 100644 index 0000000..4250116 --- /dev/null +++ b/spec/conductor/worker/task_lease_renewal_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'timeout' + +RSpec.describe Conductor::Worker::TaskRunner, '#execute_task' do + let(:client) { instance_double(Conductor::Client::TaskClient) } + let(:heartbeats) { Queue.new } + let(:heartbeat_threads) { [] } + let(:worker) do + Conductor::Worker::Worker.new('long_tool', lease_extend_enabled: true, worker_id: 'worker-1') do |task| + result = Timeout.timeout(2) { heartbeats.pop } + expect(result.task_id).to eq(task.task_id) + { 'done' => true } + end + end + let(:runner) do + described_class.new(worker, configuration: Conductor::Configuration.new, logger: Logger.new(nil)) + end + let(:task) do + Conductor::Http::Models::Task.new(task_id: 'task-1', workflow_instance_id: 'workflow-1', response_timeout_seconds: 0.02) + end + + before do + allow(Conductor::Client::TaskClient).to receive(:new).and_return(client) + allow(client).to receive(:update_task) do |result| + heartbeat_threads << Thread.current + heartbeats << result + end + end + + it 'renews each claimed task and stops its heartbeat before submitting the final result' do + next_task = Conductor::Http::Models::Task.new(task_id: 'task-2', workflow_instance_id: 'workflow-1', response_timeout_seconds: 0.02) + expect(client).to receive(:update_task_v2).with(have_attributes(task_id: 'task-1', status: 'COMPLETED', extend_lease: false)).ordered do + expect(heartbeat_threads.last).not_to be_alive + next_task + end + expect(client).to receive(:update_task_v2).with(have_attributes(task_id: 'task-2', status: 'COMPLETED', extend_lease: false)).ordered do + expect(heartbeat_threads.last).not_to be_alive + nil + end + + runner.send(:execute_and_update, task) + expect(client).to have_received(:update_task).with(have_attributes(worker_id: 'worker-1', status: 'IN_PROGRESS', extend_lease: true)).at_least(:twice) + end + + it 'honors an environment override that disables renewal' do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('CONDUCTOR_WORKER_LONG_TOOL_LEASE_EXTEND_ENABLED', nil).and_return('false') + allow(worker).to receive(:execute).and_return(Conductor::Http::Models::TaskResult.complete) + expect(client).not_to receive(:update_task) + runner.send(:execute_task, task) + end + + it 'keeps renewing an executing task during graceful shutdown' do + active_runner = runner + allow(worker).to receive(:execute) do + active_runner.shutdown + result = Timeout.timeout(2) { heartbeats.pop } + expect(result.extend_lease).to be true + Conductor::Http::Models::TaskResult.complete + end + expect(runner.send(:execute_task, task).status).to eq('COMPLETED') + end +end diff --git a/spec/conductor/worker/task_runner_spec.rb b/spec/conductor/worker/task_runner_spec.rb index 669d32d..e98bdff 100644 --- a/spec/conductor/worker/task_runner_spec.rb +++ b/spec/conductor/worker/task_runner_spec.rb @@ -37,6 +37,7 @@ allow(Conductor::Client::TaskClient).to receive(:new).and_return(task_client) allow(task_client).to receive(:batch_poll_tasks).and_return([]) allow(task_client).to receive(:update_task) + allow(task_client).to receive(:update_task_v2) end describe '#initialize' do @@ -260,7 +261,7 @@ before do allow(task_client).to receive(:batch_poll_tasks).and_return([task_data]) - allow(task_client).to receive(:update_task).and_raise(StandardError.new('Update failed')) + allow(task_client).to receive(:update_task_v2).and_raise(StandardError.new('Update failed')) event_dispatcher.register(Conductor::Worker::Events::TaskUpdateFailure, ->(event) { received_events << [:update_failure, event] }) @@ -365,7 +366,7 @@ before do allow(task_client).to receive(:batch_poll_tasks).and_return([task_data]) - allow(task_client).to receive(:update_task).and_raise(StandardError.new('Update failed')) + allow(task_client).to receive(:update_task_v2).and_raise(StandardError.new('Update failed')) event_dispatcher.register(Conductor::Worker::Events::TaskUpdateFailure, ->(event) { received_events << [:update_failure, event] }) diff --git a/spec/conductor/worker/task_update_v2_spec.rb b/spec/conductor/worker/task_update_v2_spec.rb new file mode 100644 index 0000000..5b3826a --- /dev/null +++ b/spec/conductor/worker/task_update_v2_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Conductor::Worker::TaskRunner, '#send_task_update' do + let(:configuration) { Conductor::Configuration.new(server_api_url: 'http://localhost:8080/api') } + let(:task_client) { instance_double(Conductor::Client::TaskClient) } + let(:worker) { Conductor::Worker::Worker.new('t') { {} } } + let(:runner) do + allow(Conductor::Client::TaskClient).to receive(:new).and_return(task_client) + described_class.new(worker, configuration: configuration, logger: Logger.new(nil)) + end + let(:result) { Conductor::Http::Models::TaskResult.complete } + + it 'posts to update-v2 with extendLease false by default' do + expect(task_client).to receive(:update_task_v2) do |task_result| + expect(task_result.extend_lease).to be false + nil + end + runner.send(:send_task_update, result) + end + + it 'falls back to /tasks once when the server does not serve update-v2' do + expect(task_client).to receive(:update_task_v2).once.and_raise(Conductor::ApiError.new('nope', status: 404)) + expect(task_client).to receive(:update_task).twice + runner.send(:send_task_update, result) + runner.send(:send_task_update, result) + end + + it 're-raises other API errors' do + allow(task_client).to receive(:update_task_v2).and_raise(Conductor::ApiError.new('boom', status: 500)) + expect { runner.send(:send_task_update, result) }.to raise_error(Conductor::ApiError) + end + + it 'executes every task claimed by update-v2 on the same executor slot' do + tasks = (1..3).map { |id| Conductor::Http::Models::Task.new(task_id: id.to_s, workflow_instance_id: 'wf', input_data: {}) } + allow(worker).to receive(:execute).and_call_original + expect(task_client).to receive(:update_task_v2).with(have_attributes(task_id: '1')).ordered.and_return(tasks[1]) + expect(task_client).to receive(:update_task_v2).with(have_attributes(task_id: '2')).ordered.and_return(tasks[2]) + expect(task_client).to receive(:update_task_v2).with(have_attributes(task_id: '3')).ordered.and_return(nil) + runner.send(:execute_and_update, tasks.first) + tasks.each { |task| expect(worker).to have_received(:execute).with(task).once } + expect(Conductor::Worker::TaskContext.current).to be_nil + end + + it 'does not claim more tasks after shutdown' do + runner.shutdown + expect(task_client).not_to receive(:update_task_v2) + expect(task_client).to receive(:update_task).with(result) + runner.send(:send_task_update, result) + end +end + +RSpec.describe Conductor::Worker::Worker, '#lease_extend_enabled' do + it 'defaults to false and can be enabled' do + expect(described_class.new('t') { {} }.lease_extend_enabled).to be false + expect(described_class.new('t', lease_extend_enabled: true) { {} }.lease_extend_enabled).to be true + end +end diff --git a/spec/fixtures/agents/README.md b/spec/fixtures/agents/README.md new file mode 100644 index 0000000..a25ea17 --- /dev/null +++ b/spec/fixtures/agents/README.md @@ -0,0 +1,21 @@ +# Agent contract fixtures + +Python-generated expectations for the Ruby `ConfigSerializer`. Nothing here is produced by Ruby. + +- `agent-schema.json`: the server's agent config schema, vendored from Conductor. +- `configs/`: golden `agentConfig` outputs from the Python SDK's contract suite. + `103_plan_and_compile.json` adds the planner/fallback config from Python's `plan_execute`. +- `examples/`: one file per ported Python example, from + `conductor-oss/python-sdk@c99e2cf9871c21f7a64d823126ee1b77989b00ad` (checked against + `main` on 2026-09-17). Each file is an array of configs in execution order; an agent run + more than once appears once. All `model` fields are normalized to `openai/gpt-4o-mini`. + +`spec/conductor/agents/examples_spec.rb` builds each Ruby example and compares its full +config with `examples/`. `contract_spec.rb` validates `configs/` against the schema. + +To refresh `examples/`: import the Python example, serialize each agent it passes to +`runtime.run` or `start` with `AgentConfigSerializer.serialize`, normalize models +recursively, and write sorted, indented JSON. Review the source revision and the fixture +diff together. + +LLM recordings for playback live in `llm-recordings/` in conductor-oss/conductor, not here. diff --git a/spec/fixtures/agents/agent-schema.json b/spec/fixtures/agents/agent-schema.json new file mode 100644 index 0000000..12656b4 --- /dev/null +++ b/spec/fixtures/agents/agent-schema.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/conductor-oss/python-sdk/blob/main/docs/agents/reference/agent-schema.json", + "title": "Conductor Python AgentConfig", + "description": "The public wire shape emitted by AgentConfigSerializer under agentConfig.", + "$ref": "#/$defs/agentConfig", + "$defs": { + "jsonValue": { + "anyOf": [ + { "type": "string" }, { "type": "number" }, { "type": "integer" }, + { "type": "boolean" }, { "type": "null" }, + { "type": "array", "items": { "$ref": "#/$defs/jsonValue" } }, + { "type": "object", "additionalProperties": { "$ref": "#/$defs/jsonValue" } } + ] + }, + "taskReference": { + "type": "object", + "required": ["taskName"], + "properties": { "taskName": { "type": "string", "minLength": 1 } }, + "additionalProperties": false + }, + "tool": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "toolType": { "type": "string" }, + "taskName": { "type": "string" }, + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" }, + "credentials": { "type": "array", "items": { "type": "string" } }, + "approvalRequired": { "type": "boolean" } + }, + "additionalProperties": true + }, + "agentConfig": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_-]*$" }, + "model": { "type": ["string", "null"] }, + "baseUrl": { "type": ["string", "null"] }, + "strategy": { "type": ["string", "null"] }, + "maxTurns": { "type": ["integer", "null"], "minimum": 0 }, + "timeoutSeconds": { "type": ["integer", "null"], "minimum": 0 }, + "external": { "type": ["boolean", "null"] }, + "instructions": { "anyOf": [{ "type": "string" }, { "type": "object" }, { "type": "null" }] }, + "tools": { "type": "array", "items": { "$ref": "#/$defs/tool" } }, + "agents": { "type": "array", "items": { "$ref": "#/$defs/agentConfig" } }, + "router": { "anyOf": [{ "$ref": "#/$defs/taskReference" }, { "$ref": "#/$defs/agentConfig" }] }, + "outputType": { "type": "object" }, + "guardrails": { "type": "array", "items": { "type": "object" } }, + "memory": { "type": "object" }, + "maxTokens": { "type": "integer", "minimum": 0 }, + "contextWindowBudget": { "type": "integer", "minimum": 0 }, + "temperature": { "type": "number" }, + "reasoningEffort": { "type": "string" }, + "stopWhen": { "$ref": "#/$defs/taskReference" }, + "termination": { "type": "object" }, + "handoffs": { "type": "array", "items": { "type": "object" } }, + "allowedTransitions": { "type": "array", "items": { "type": "string" } }, + "introduction": { "type": "string" }, + "metadata": { "type": "object" }, + "enablePlanning": { "type": "boolean" }, + "planner": { "$ref": "#/$defs/agentConfig" }, + "fallback": { "$ref": "#/$defs/agentConfig" }, + "callbacks": { "type": "array", "items": { "type": "object" } }, + "includeContents": { "type": "boolean" }, + "thinkingConfig": { "type": "object" }, + "requiredTools": { "type": "array", "items": { "type": "string" } }, + "prefillTools": { "type": "array", "items": { "type": "object" } }, + "fallbackMaxTurns": { "type": "integer", "minimum": 0 }, + "planSource": { "type": "string" }, + "plannerContext": { "type": "array", "items": { "type": "object" } }, + "synthesize": { "type": "boolean" }, + "maskedFields": { "type": "array", "items": { "type": "string" } }, + "gate": { "type": "object" }, + "codeExecution": { "type": "object" }, + "cliConfig": { "type": "object" }, + "credentials": { "type": "array", "items": { "type": "string" } }, + "_framework": { "type": "string" } + }, + "additionalProperties": false + } + } +} diff --git a/spec/fixtures/agents/configs/01_basic_agent.json b/spec/fixtures/agents/configs/01_basic_agent.json new file mode 100644 index 0000000..62be834 --- /dev/null +++ b/spec/fixtures/agents/configs/01_basic_agent.json @@ -0,0 +1,7 @@ +{ + "external": false, + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "greeter", + "timeoutSeconds": 0 +} \ No newline at end of file diff --git a/spec/fixtures/agents/configs/02_tools.json b/spec/fixtures/agents/configs/02_tools.json new file mode 100644 index 0000000..8648227 --- /dev/null +++ b/spec/fixtures/agents/configs/02_tools.json @@ -0,0 +1,80 @@ +{ + "external": false, + "instructions": "You are a helpful assistant with access to weather, calculator, and email tools.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "tool_demo_agent", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Get current weather for a city.", + "inputSchema": { + "properties": { + "city": { + "type": "string" + } + }, + "required": [ + "city" + ], + "type": "object" + }, + "name": "get_weather", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + }, + { + "description": "Evaluate a math expression.", + "inputSchema": { + "properties": { + "expression": { + "type": "string" + } + }, + "required": [ + "expression" + ], + "type": "object" + }, + "name": "calculate", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + }, + { + "approvalRequired": true, + "description": "Send an email.", + "inputSchema": { + "properties": { + "body": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "to": { + "type": "string" + } + }, + "required": [ + "to", + "subject", + "body" + ], + "type": "object" + }, + "name": "send_email", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "timeoutSeconds": 60, + "toolType": "worker" + } + ] +} \ No newline at end of file diff --git a/spec/fixtures/agents/configs/03_structured_output.json b/spec/fixtures/agents/configs/03_structured_output.json new file mode 100644 index 0000000..b79ea4a --- /dev/null +++ b/spec/fixtures/agents/configs/03_structured_output.json @@ -0,0 +1,61 @@ +{ + "external": false, + "instructions": "You are a weather reporter. Get the weather and provide a recommendation.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "weather_reporter", + "outputType": { + "className": "WeatherReport", + "schema": { + "properties": { + "city": { + "title": "City", + "type": "string" + }, + "condition": { + "title": "Condition", + "type": "string" + }, + "recommendation": { + "title": "Recommendation", + "type": "string" + }, + "temperature": { + "title": "Temperature", + "type": "number" + } + }, + "required": [ + "city", + "temperature", + "condition", + "recommendation" + ], + "title": "WeatherReport", + "type": "object" + } + }, + "timeoutSeconds": 0, + "tools": [ + { + "description": "Get current weather data for a city.", + "inputSchema": { + "properties": { + "city": { + "type": "string" + } + }, + "required": [ + "city" + ], + "type": "object" + }, + "name": "get_weather", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] +} \ No newline at end of file diff --git a/spec/fixtures/agents/configs/05_handoffs.json b/spec/fixtures/agents/configs/05_handoffs.json new file mode 100644 index 0000000..1bd3afa --- /dev/null +++ b/spec/fixtures/agents/configs/05_handoffs.json @@ -0,0 +1,101 @@ +{ + "agents": [ + { + "external": false, + "instructions": "You handle billing questions: balances, payments, invoices.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "billing", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Check the balance of a bank account.", + "inputSchema": { + "properties": { + "account_id": { + "type": "string" + } + }, + "required": [ + "account_id" + ], + "type": "object" + }, + "name": "check_balance", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] + }, + { + "external": false, + "instructions": "You handle technical questions: order status, shipping, returns.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "technical", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Look up the status of an order.", + "inputSchema": { + "properties": { + "order_id": { + "type": "string" + } + }, + "required": [ + "order_id" + ], + "type": "object" + }, + "name": "lookup_order", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] + }, + { + "external": false, + "instructions": "You handle sales questions: pricing, products, promotions.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "sales", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Get pricing information for a product.", + "inputSchema": { + "properties": { + "product": { + "type": "string" + } + }, + "required": [ + "product" + ], + "type": "object" + }, + "name": "get_pricing", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] + } + ], + "external": false, + "instructions": "Route customer requests to the right specialist: billing, technical, or sales.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "support", + "strategy": "handoff", + "timeoutSeconds": 0 +} \ No newline at end of file diff --git a/spec/fixtures/agents/configs/06_sequential_pipeline.json b/spec/fixtures/agents/configs/06_sequential_pipeline.json new file mode 100644 index 0000000..d224d42 --- /dev/null +++ b/spec/fixtures/agents/configs/06_sequential_pipeline.json @@ -0,0 +1,34 @@ +{ + "agents": [ + { + "external": false, + "instructions": "You are a researcher. Given a topic, provide key facts and data points. Be thorough but concise. Output raw research findings.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "researcher", + "timeoutSeconds": 0 + }, + { + "external": false, + "instructions": "You are a writer. Take research findings and write a clear, engaging article. Use headers and bullet points where appropriate.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "writer", + "timeoutSeconds": 0 + }, + { + "external": false, + "instructions": "You are an editor. Review the article for clarity, grammar, and tone. Make improvements and output the final polished version.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "editor", + "timeoutSeconds": 0 + } + ], + "external": false, + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "researcher_writer_editor", + "strategy": "sequential", + "timeoutSeconds": 0 +} \ No newline at end of file diff --git a/spec/fixtures/agents/configs/07_parallel_agents.json b/spec/fixtures/agents/configs/07_parallel_agents.json new file mode 100644 index 0000000..83f6025 --- /dev/null +++ b/spec/fixtures/agents/configs/07_parallel_agents.json @@ -0,0 +1,34 @@ +{ + "agents": [ + { + "external": false, + "instructions": "You are a market analyst. Analyze the given topic from a market perspective: market size, growth trends, key players, and opportunities.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "market_analyst", + "timeoutSeconds": 0 + }, + { + "external": false, + "instructions": "You are a risk analyst. Analyze the given topic for risks: regulatory risks, technical risks, competitive threats, and mitigation strategies.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "risk_analyst", + "timeoutSeconds": 0 + }, + { + "external": false, + "instructions": "You are a compliance specialist. Check the given topic for compliance considerations: data privacy, regulatory requirements, and industry standards.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "compliance", + "timeoutSeconds": 0 + } + ], + "external": false, + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "analysis", + "strategy": "parallel", + "timeoutSeconds": 0 +} \ No newline at end of file diff --git a/spec/fixtures/agents/configs/08_router_agent.json b/spec/fixtures/agents/configs/08_router_agent.json new file mode 100644 index 0000000..fcac598 --- /dev/null +++ b/spec/fixtures/agents/configs/08_router_agent.json @@ -0,0 +1,43 @@ +{ + "agents": [ + { + "external": false, + "instructions": "You create implementation plans. Break down tasks into clear numbered steps.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "planner", + "timeoutSeconds": 0 + }, + { + "external": false, + "instructions": "You write code. Output clean, well-documented Python code.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "coder", + "timeoutSeconds": 0 + }, + { + "external": false, + "instructions": "You review code. Check for bugs, style issues, and suggest improvements.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "reviewer", + "timeoutSeconds": 0 + } + ], + "external": false, + "instructions": "You are the tech lead. Route requests to the right team member: planner for design/architecture, coder for implementation, reviewer for code review.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "dev_team", + "router": { + "external": false, + "instructions": "You create implementation plans. Break down tasks into clear numbered steps.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "planner", + "timeoutSeconds": 0 + }, + "strategy": "router", + "timeoutSeconds": 0 +} \ No newline at end of file diff --git a/spec/fixtures/agents/configs/103_plan_and_compile.json b/spec/fixtures/agents/configs/103_plan_and_compile.json new file mode 100644 index 0000000..9634f16 --- /dev/null +++ b/spec/fixtures/agents/configs/103_plan_and_compile.json @@ -0,0 +1,151 @@ +{ + "external": false, + "fallback": { + "external": false, + "instructions": "The plan failed. Use the available tools to recover.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "plan_and_compile_demo_fallback", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Compute n! and return it as a string.\n\nArgs:\n n: Non-negative integer. Capped at 20 to keep things sane.", + "inputSchema": { + "properties": { + "n": { + "type": "integer" + } + }, + "required": [ + "n" + ], + "type": "object" + }, + "name": "factorial", + "outputSchema": { + "type": "string" + }, + "toolType": "worker" + }, + { + "description": "Persist a short summary string. Returns it back for the validator.", + "inputSchema": { + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "name": "write_summary", + "outputSchema": { + "type": "string" + }, + "toolType": "worker" + }, + { + "description": "Return JSON ``{passed, length, min_chars}`` for the validator.\n\nArgs:\n text: The summary to check.\n min_chars: Minimum acceptable length in characters.", + "inputSchema": { + "properties": { + "min_chars": { + "type": "integer" + }, + "text": { + "type": "string" + } + }, + "required": [ + "text", + "min_chars" + ], + "type": "object" + }, + "name": "check_summary", + "outputSchema": { + "type": "string" + }, + "toolType": "worker" + } + ] + }, + "fallbackMaxTurns": 4, + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "plan_and_compile_demo", + "planner": { + "external": false, + "instructions": "You are a math-explainer planner. Plan a workflow that:\n\n1. Computes factorials of 1, 2, 3, 4, 5 in PARALLEL using ``factorial`` (static args).\n2. Writes a short prose summary about factorial growth using ``write_summary``\n (use a ``generate`` block \u2014 the LLM produces the ``text`` arg at run time).\n3. Validates the summary is at least 30 characters via ``check_summary``,\n with ``success_condition: \"$.passed === true\"``.\n", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "plan_and_compile_demo_planner", + "timeoutSeconds": 0 + }, + "strategy": "plan_execute", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Compute n! and return it as a string.\n\nArgs:\n n: Non-negative integer. Capped at 20 to keep things sane.", + "inputSchema": { + "properties": { + "n": { + "type": "integer" + } + }, + "required": [ + "n" + ], + "type": "object" + }, + "name": "factorial", + "outputSchema": { + "type": "string" + }, + "toolType": "worker" + }, + { + "description": "Persist a short summary string. Returns it back for the validator.", + "inputSchema": { + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "name": "write_summary", + "outputSchema": { + "type": "string" + }, + "toolType": "worker" + }, + { + "description": "Return JSON ``{passed, length, min_chars}`` for the validator.\n\nArgs:\n text: The summary to check.\n min_chars: Minimum acceptable length in characters.", + "inputSchema": { + "properties": { + "min_chars": { + "type": "integer" + }, + "text": { + "type": "string" + } + }, + "required": [ + "text", + "min_chars" + ], + "type": "object" + }, + "name": "check_summary", + "outputSchema": { + "type": "string" + }, + "toolType": "worker" + } + ] +} diff --git a/spec/fixtures/agents/configs/10_guardrails.json b/spec/fixtures/agents/configs/10_guardrails.json new file mode 100644 index 0000000..443dd21 --- /dev/null +++ b/spec/fixtures/agents/configs/10_guardrails.json @@ -0,0 +1,60 @@ +{ + "external": false, + "guardrails": [ + { + "guardrailType": "custom", + "maxRetries": 3, + "name": "no_pii", + "onFail": "retry", + "position": "output", + "taskName": "no_pii" + } + ], + "instructions": "You are a customer support assistant. Use the available tools to answer questions about orders and customers. Always include all details from the tool results in your response.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "support_agent", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Look up the current status of an order.", + "inputSchema": { + "properties": { + "order_id": { + "type": "string" + } + }, + "required": [ + "order_id" + ], + "type": "object" + }, + "name": "get_order_status", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + }, + { + "description": "Retrieve customer details including payment info on file.", + "inputSchema": { + "properties": { + "customer_id": { + "type": "string" + } + }, + "required": [ + "customer_id" + ], + "type": "object" + }, + "name": "get_customer_info", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] +} \ No newline at end of file diff --git a/spec/fixtures/agents/configs/13_hierarchical_agents.json b/spec/fixtures/agents/configs/13_hierarchical_agents.json new file mode 100644 index 0000000..b06385a --- /dev/null +++ b/spec/fixtures/agents/configs/13_hierarchical_agents.json @@ -0,0 +1,77 @@ +{ + "agents": [ + { + "agents": [ + { + "external": false, + "instructions": "You are a backend developer. You design APIs, databases, and server architecture. Provide technical recommendations with code examples.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "backend_dev", + "timeoutSeconds": 0 + }, + { + "external": false, + "instructions": "You are a frontend developer. You design UI components, user flows, and client-side architecture. Provide recommendations with code examples.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "frontend_dev", + "timeoutSeconds": 0 + } + ], + "external": false, + "instructions": "You are the engineering lead. Route technical questions to the right specialist: backend_dev for APIs/databases/servers, frontend_dev for UI/UX/client-side.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "engineering_lead", + "strategy": "handoff", + "timeoutSeconds": 0 + }, + { + "agents": [ + { + "external": false, + "instructions": "You are a content writer. You create blog posts, landing page copy, and marketing materials. Write engaging, clear content.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "content_writer", + "timeoutSeconds": 0 + }, + { + "external": false, + "instructions": "You are an SEO specialist. You optimize content for search engines, suggest keywords, and improve page rankings.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "seo_specialist", + "timeoutSeconds": 0 + } + ], + "external": false, + "instructions": "You are the marketing lead. Route marketing questions to the right specialist: content_writer for blog posts/copy, seo_specialist for SEO/keywords/rankings.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "marketing_lead", + "strategy": "handoff", + "timeoutSeconds": 0 + } + ], + "external": false, + "handoffs": [ + { + "target": "engineering_lead", + "text": "engineering_lead", + "type": "on_text_mention" + }, + { + "target": "marketing_lead", + "text": "marketing_lead", + "type": "on_text_mention" + } + ], + "instructions": "You are the CEO. Route requests to the right department: engineering_lead for technical/development questions, marketing_lead for marketing/content/SEO questions.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "ceo", + "strategy": "swarm", + "timeoutSeconds": 0 +} \ No newline at end of file diff --git a/spec/fixtures/agents/configs/17_swarm_orchestration.json b/spec/fixtures/agents/configs/17_swarm_orchestration.json new file mode 100644 index 0000000..f4bc0af --- /dev/null +++ b/spec/fixtures/agents/configs/17_swarm_orchestration.json @@ -0,0 +1,39 @@ +{ + "agents": [ + { + "external": false, + "instructions": "You are a refund specialist. Process the customer's refund request. Check eligibility, confirm the refund amount, and let them know the timeline. Be empathetic and clear. Do NOT ask follow-up questions -- just process the refund based on what the customer told you.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "refund_specialist", + "timeoutSeconds": 0 + }, + { + "external": false, + "instructions": "You are a technical support specialist. Diagnose the customer's technical issue and provide clear troubleshooting steps.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "tech_support", + "timeoutSeconds": 0 + } + ], + "external": false, + "handoffs": [ + { + "target": "refund_specialist", + "text": "refund", + "type": "on_text_mention" + }, + { + "target": "tech_support", + "text": "technical", + "type": "on_text_mention" + } + ], + "instructions": "You are the front-line customer support agent. Triage customer requests. If the customer needs a refund, transfer to the refund specialist. If they have a technical issue, transfer to tech support. Use the transfer tools available to you to hand off the conversation.", + "maxTurns": 3, + "model": "anthropic/claude-sonnet-4-6", + "name": "support", + "strategy": "swarm", + "timeoutSeconds": 0 +} \ No newline at end of file diff --git a/spec/fixtures/agents/configs/19_composable_termination_and.json b/spec/fixtures/agents/configs/19_composable_termination_and.json new file mode 100644 index 0000000..932ab3a --- /dev/null +++ b/spec/fixtures/agents/configs/19_composable_termination_and.json @@ -0,0 +1,43 @@ +{ + "external": false, + "instructions": "Research thoroughly. Only provide your FINAL ANSWER after using the search tool at least twice.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "deliberator", + "termination": { + "conditions": [ + { + "caseSensitive": false, + "text": "FINAL ANSWER", + "type": "text_mention" + }, + { + "maxMessages": 5, + "type": "max_message" + } + ], + "type": "and" + }, + "timeoutSeconds": 0, + "tools": [ + { + "description": "Search for information.", + "inputSchema": { + "properties": { + "query": { + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "search", + "outputSchema": { + "type": "string" + }, + "toolType": "worker" + } + ] +} \ No newline at end of file diff --git a/spec/fixtures/agents/configs/19_composable_termination_complex.json b/spec/fixtures/agents/configs/19_composable_termination_complex.json new file mode 100644 index 0000000..99cf2e6 --- /dev/null +++ b/spec/fixtures/agents/configs/19_composable_termination_complex.json @@ -0,0 +1,56 @@ +{ + "external": false, + "instructions": "Research and provide a comprehensive answer.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "complex_agent", + "termination": { + "conditions": [ + { + "stopMessage": "TERMINATE", + "type": "stop_message" + }, + { + "conditions": [ + { + "caseSensitive": false, + "text": "DONE", + "type": "text_mention" + }, + { + "maxMessages": 10, + "type": "max_message" + } + ], + "type": "and" + }, + { + "maxTotalTokens": 50000, + "type": "token_usage" + } + ], + "type": "or" + }, + "timeoutSeconds": 0, + "tools": [ + { + "description": "Search for information.", + "inputSchema": { + "properties": { + "query": { + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "search", + "outputSchema": { + "type": "string" + }, + "toolType": "worker" + } + ] +} \ No newline at end of file diff --git a/spec/fixtures/agents/configs/19_composable_termination_or.json b/spec/fixtures/agents/configs/19_composable_termination_or.json new file mode 100644 index 0000000..6ff6ca6 --- /dev/null +++ b/spec/fixtures/agents/configs/19_composable_termination_or.json @@ -0,0 +1,22 @@ +{ + "external": false, + "instructions": "Have a conversation. Say GOODBYE when you're finished.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "chatbot", + "termination": { + "conditions": [ + { + "caseSensitive": false, + "text": "GOODBYE", + "type": "text_mention" + }, + { + "maxMessages": 20, + "type": "max_message" + } + ], + "type": "or" + }, + "timeoutSeconds": 0 +} \ No newline at end of file diff --git a/spec/fixtures/agents/configs/19_composable_termination_simple.json b/spec/fixtures/agents/configs/19_composable_termination_simple.json new file mode 100644 index 0000000..596e674 --- /dev/null +++ b/spec/fixtures/agents/configs/19_composable_termination_simple.json @@ -0,0 +1,34 @@ +{ + "external": false, + "instructions": "Research the topic and say DONE when you have enough info.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "researcher", + "termination": { + "caseSensitive": false, + "text": "DONE", + "type": "text_mention" + }, + "timeoutSeconds": 0, + "tools": [ + { + "description": "Search for information.", + "inputSchema": { + "properties": { + "query": { + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "search", + "outputSchema": { + "type": "string" + }, + "toolType": "worker" + } + ] +} \ No newline at end of file diff --git a/spec/fixtures/agents/configs/21_regex_guardrails.json b/spec/fixtures/agents/configs/21_regex_guardrails.json new file mode 100644 index 0000000..3b4a0f7 --- /dev/null +++ b/spec/fixtures/agents/configs/21_regex_guardrails.json @@ -0,0 +1,56 @@ +{ + "external": false, + "guardrails": [ + { + "guardrailType": "regex", + "maxRetries": 3, + "message": "Response must not contain email addresses. Redact them.", + "mode": "block", + "name": "no_email_addresses", + "onFail": "retry", + "patterns": [ + "[\\w.+-]+@[\\w-]+\\.[\\w.-]+" + ], + "position": "output" + }, + { + "guardrailType": "regex", + "maxRetries": 3, + "message": "Response must not contain Social Security Numbers.", + "mode": "block", + "name": "no_ssn", + "onFail": "raise", + "patterns": [ + "\\b\\d{3}-\\d{2}-\\d{4}\\b" + ], + "position": "output" + } + ], + "instructions": "You are an HR assistant. When asked about employees, look up their profile and share ALL the details you find.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "hr_assistant", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Retrieve a user's profile from the database.", + "inputSchema": { + "properties": { + "user_id": { + "type": "string" + } + }, + "required": [ + "user_id" + ], + "type": "object" + }, + "name": "get_user_profile", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] +} \ No newline at end of file diff --git a/spec/fixtures/agents/configs/22_llm_guardrails.json b/spec/fixtures/agents/configs/22_llm_guardrails.json new file mode 100644 index 0000000..7f0b028 --- /dev/null +++ b/spec/fixtures/agents/configs/22_llm_guardrails.json @@ -0,0 +1,20 @@ +{ + "external": false, + "guardrails": [ + { + "guardrailType": "llm", + "maxRetries": 3, + "maxTokens": 10000, + "model": "anthropic/claude-sonnet-4-6", + "name": "content_safety", + "onFail": "retry", + "policy": "Reject any content that:\n1. Contains medical or legal advice presented as fact\n2. Makes promises or guarantees about outcomes\n3. Includes discriminatory or biased language\n4. Reveals private information about real individuals\n\nEven if there are disclaimers you should reject", + "position": "output" + } + ], + "instructions": "You are a health information assistant. Provide general health information and solution to the problem. You can prescribe psudo scientific and untested meds ", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "health_advisor", + "timeoutSeconds": 0 +} \ No newline at end of file diff --git a/spec/fixtures/agents/configs/45_agent_tool.json b/spec/fixtures/agents/configs/45_agent_tool.json new file mode 100644 index 0000000..6f49e04 --- /dev/null +++ b/spec/fixtures/agents/configs/45_agent_tool.json @@ -0,0 +1,79 @@ +{ + "external": false, + "instructions": "You are a project manager. Use the researcher tool to gather information and the calculate tool for math. Synthesize findings.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "manager_45", + "timeoutSeconds": 0, + "tools": [ + { + "config": { + "agentConfig": { + "external": false, + "instructions": "You are a research assistant. Use search_knowledge_base to find information about topics. Provide concise summaries.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "researcher_45", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Search an internal knowledge base for information.", + "inputSchema": { + "properties": { + "query": { + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "search_knowledge_base", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] + } + }, + "description": "Invoke the researcher_45 agent", + "inputSchema": { + "properties": { + "request": { + "description": "The request or question to send to this agent.", + "type": "string" + } + }, + "required": [ + "request" + ], + "type": "object" + }, + "name": "researcher_45", + "toolType": "agent_tool" + }, + { + "description": "Evaluate a math expression safely.", + "inputSchema": { + "properties": { + "expression": { + "type": "string" + } + }, + "required": [ + "expression" + ], + "type": "object" + }, + "name": "calculate", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] +} \ No newline at end of file diff --git a/spec/fixtures/agents/configs/47_callbacks.json b/spec/fixtures/agents/configs/47_callbacks.json new file mode 100644 index 0000000..6f9af80 --- /dev/null +++ b/spec/fixtures/agents/configs/47_callbacks.json @@ -0,0 +1,40 @@ +{ + "callbacks": [ + { + "position": "before_model", + "taskName": "monitored_agent_47_before_model" + }, + { + "position": "after_model", + "taskName": "monitored_agent_47_after_model" + } + ], + "external": false, + "instructions": "You are a helpful assistant. Use get_facts when asked about topics.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "monitored_agent_47", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Get interesting facts about a topic.", + "inputSchema": { + "properties": { + "topic": { + "type": "string" + } + }, + "required": [ + "topic" + ], + "type": "object" + }, + "name": "get_facts", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] +} \ No newline at end of file diff --git a/spec/fixtures/agents/configs/52_nested_strategies.json b/spec/fixtures/agents/configs/52_nested_strategies.json new file mode 100644 index 0000000..eee7b9f --- /dev/null +++ b/spec/fixtures/agents/configs/52_nested_strategies.json @@ -0,0 +1,44 @@ +{ + "agents": [ + { + "agents": [ + { + "external": false, + "instructions": "You are a market analyst. Analyze the market size, growth rate, and key players for the given topic. Be concise (3-4 bullet points).", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "market_analyst_52", + "timeoutSeconds": 0 + }, + { + "external": false, + "instructions": "You are a risk analyst. Identify the top 3 risks: regulatory, technical, and competitive. Be concise.", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "risk_analyst_52", + "timeoutSeconds": 0 + } + ], + "external": false, + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "research_phase_52", + "strategy": "parallel", + "timeoutSeconds": 0 + }, + { + "external": false, + "instructions": "You are an executive briefing writer. Synthesize the market analysis and risk assessment into a concise executive summary (1 paragraph).", + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "summarizer_52", + "timeoutSeconds": 0 + } + ], + "external": false, + "maxTurns": 25, + "model": "anthropic/claude-sonnet-4-6", + "name": "research_phase_52_summarizer_52", + "strategy": "sequential", + "timeoutSeconds": 0 +} \ No newline at end of file diff --git a/spec/fixtures/agents/examples/01_basic_agent.json b/spec/fixtures/agents/examples/01_basic_agent.json new file mode 100644 index 0000000..fd14453 --- /dev/null +++ b/spec/fixtures/agents/examples/01_basic_agent.json @@ -0,0 +1,10 @@ +[ + { + "external": false, + "instructions": "You are a friendly assistant. Keep responses brief.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "greeter", + "timeoutSeconds": 0 + } +] diff --git a/spec/fixtures/agents/examples/02a_simple_tools.json b/spec/fixtures/agents/examples/02a_simple_tools.json new file mode 100644 index 0000000..8d7815e --- /dev/null +++ b/spec/fixtures/agents/examples/02a_simple_tools.json @@ -0,0 +1,52 @@ +[ + { + "external": false, + "instructions": "You are a helpful assistant. Use tools to answer questions.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "weather_stock_agent", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Get the current weather for a city.", + "inputSchema": { + "properties": { + "city": { + "type": "string" + } + }, + "required": [ + "city" + ], + "type": "object" + }, + "name": "get_weather", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + }, + { + "description": "Get the current stock price for a ticker symbol.", + "inputSchema": { + "properties": { + "symbol": { + "type": "string" + } + }, + "required": [ + "symbol" + ], + "type": "object" + }, + "name": "get_stock_price", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] + } +] diff --git a/spec/fixtures/agents/examples/02c_tool_retry_config.json b/spec/fixtures/agents/examples/02c_tool_retry_config.json new file mode 100644 index 0000000..46b22dc --- /dev/null +++ b/spec/fixtures/agents/examples/02c_tool_retry_config.json @@ -0,0 +1,72 @@ +[ + { + "external": false, + "instructions": "You help users fetch and process data. Use the appropriate tool for each request.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "retry_config_demo", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Call an unreliable external API that may need aggressive retries.", + "inputSchema": { + "properties": { + "query": { + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "call_external_api", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + }, + { + "description": "Run a database query with fixed-interval retries for transient connection issues.", + "inputSchema": { + "properties": { + "sql": { + "type": "string" + } + }, + "required": [ + "sql" + ], + "type": "object" + }, + "name": "query_database", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + }, + { + "description": "Process data locally \u2014 light retries with linear backoff.", + "inputSchema": { + "properties": { + "data": { + "type": "string" + } + }, + "required": [ + "data" + ], + "type": "object" + }, + "name": "process_data", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] + } +] diff --git a/spec/fixtures/agents/examples/04_http_and_mcp_tools.json b/spec/fixtures/agents/examples/04_http_and_mcp_tools.json new file mode 100644 index 0000000..7a21213 --- /dev/null +++ b/spec/fixtures/agents/examples/04_http_and_mcp_tools.json @@ -0,0 +1,83 @@ +[ + { + "external": false, + "instructions": "You can reverse strings and format reports. When asked to reverse a string, use reverse_string first, then format_report with the result.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "http_tools_demo", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Format a title and body into a structured report.", + "inputSchema": { + "properties": { + "body": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "title", + "body" + ], + "type": "object" + }, + "name": "format_report", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + }, + { + "config": { + "accept": [ + "application/json" + ], + "contentType": "application/json", + "credentials": [ + "HTTP_TEST_API_KEY" + ], + "headers": { + "Authorization": "Bearer ${HTTP_TEST_API_KEY}" + }, + "method": "POST", + "url": "http://localhost:3001/api/string/reverse" + }, + "description": "Reverse a string using the HTTP API", + "inputSchema": { + "properties": { + "text": { + "description": "Text to reverse", + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "name": "reverse_string", + "toolType": "http" + }, + { + "config": { + "credentials": [ + "MCP_TEST_API_KEY" + ], + "headers": { + "Authorization": "Bearer ${MCP_TEST_API_KEY}" + }, + "max_tools": 64, + "server_url": "http://localhost:3001/mcp" + }, + "description": "Deterministic test tools via MCP \u2014 math, string, collection, encoding, hash, datetime, validation, and conversion operations.", + "inputSchema": {}, + "name": "mcp_test_tools", + "toolType": "mcp" + } + ] + } +] diff --git a/spec/fixtures/agents/examples/05_handoffs.json b/spec/fixtures/agents/examples/05_handoffs.json new file mode 100644 index 0000000..182d4bf --- /dev/null +++ b/spec/fixtures/agents/examples/05_handoffs.json @@ -0,0 +1,103 @@ +[ + { + "agents": [ + { + "external": false, + "instructions": "You handle billing questions: balances, payments, invoices.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "billing", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Check the balance of a bank account.", + "inputSchema": { + "properties": { + "account_id": { + "type": "string" + } + }, + "required": [ + "account_id" + ], + "type": "object" + }, + "name": "check_balance", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] + }, + { + "external": false, + "instructions": "You handle technical questions: order status, shipping, returns.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "technical", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Look up the status of an order.", + "inputSchema": { + "properties": { + "order_id": { + "type": "string" + } + }, + "required": [ + "order_id" + ], + "type": "object" + }, + "name": "lookup_order", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] + }, + { + "external": false, + "instructions": "You handle sales questions: pricing, products, promotions.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "sales", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Get pricing information for a product.", + "inputSchema": { + "properties": { + "product": { + "type": "string" + } + }, + "required": [ + "product" + ], + "type": "object" + }, + "name": "get_pricing", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] + } + ], + "external": false, + "instructions": "Route customer requests to the right specialist: billing, technical, or sales.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "support", + "strategy": "handoff", + "timeoutSeconds": 0 + } +] diff --git a/spec/fixtures/agents/examples/06_sequential_pipeline.json b/spec/fixtures/agents/examples/06_sequential_pipeline.json new file mode 100644 index 0000000..d0afcfb --- /dev/null +++ b/spec/fixtures/agents/examples/06_sequential_pipeline.json @@ -0,0 +1,36 @@ +[ + { + "agents": [ + { + "external": false, + "instructions": "You are a researcher. Given a topic, provide key facts and data points. Be thorough but concise. Output raw research findings.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "researcher", + "timeoutSeconds": 0 + }, + { + "external": false, + "instructions": "You are a writer. Take research findings and write a clear, engaging article. Use headers and bullet points where appropriate.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "writer", + "timeoutSeconds": 0 + }, + { + "external": false, + "instructions": "You are an editor. Review the article for clarity, grammar, and tone. Make improvements and output the final polished version.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "editor", + "timeoutSeconds": 0 + } + ], + "external": false, + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "researcher_writer_editor", + "strategy": "sequential", + "timeoutSeconds": 0 + } +] diff --git a/spec/fixtures/agents/examples/07_parallel_agents.json b/spec/fixtures/agents/examples/07_parallel_agents.json new file mode 100644 index 0000000..15b875e --- /dev/null +++ b/spec/fixtures/agents/examples/07_parallel_agents.json @@ -0,0 +1,36 @@ +[ + { + "agents": [ + { + "external": false, + "instructions": "You are a market analyst. Analyze the given topic from a market perspective: market size, growth trends, key players, and opportunities.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "market_analyst", + "timeoutSeconds": 0 + }, + { + "external": false, + "instructions": "You are a risk analyst. Analyze the given topic for risks: regulatory risks, technical risks, competitive threats, and mitigation strategies.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "risk_analyst", + "timeoutSeconds": 0 + }, + { + "external": false, + "instructions": "You are a compliance specialist. Check the given topic for compliance considerations: data privacy, regulatory requirements, and industry standards.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "compliance", + "timeoutSeconds": 0 + } + ], + "external": false, + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "analysis", + "strategy": "parallel", + "timeoutSeconds": 0 + } +] diff --git a/spec/fixtures/agents/examples/09_human_in_the_loop.json b/spec/fixtures/agents/examples/09_human_in_the_loop.json new file mode 100644 index 0000000..7310689 --- /dev/null +++ b/spec/fixtures/agents/examples/09_human_in_the_loop.json @@ -0,0 +1,61 @@ +[ + { + "external": false, + "instructions": "You are a banking assistant. Use check_balance for balance inquiries. When asked to transfer money, first check the balance, then call transfer_funds to request the transfer. The runtime will pause for human approval before the transfer executes.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "banker", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Check the balance of an account.", + "inputSchema": { + "properties": { + "account_id": { + "type": "string" + } + }, + "required": [ + "account_id" + ], + "type": "object" + }, + "name": "check_balance", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + }, + { + "approvalRequired": true, + "description": "Request a funds transfer; runtime pauses for human approval before execution.", + "inputSchema": { + "properties": { + "amount": { + "type": "number" + }, + "from_acct": { + "type": "string" + }, + "to_acct": { + "type": "string" + } + }, + "required": [ + "from_acct", + "to_acct", + "amount" + ], + "type": "object" + }, + "name": "transfer_funds", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] + } +] diff --git a/spec/fixtures/agents/examples/09c_hitl_streaming.json b/spec/fixtures/agents/examples/09c_hitl_streaming.json new file mode 100644 index 0000000..811f5fe --- /dev/null +++ b/spec/fixtures/agents/examples/09c_hitl_streaming.json @@ -0,0 +1,77 @@ +[ + { + "external": false, + "instructions": "You are an operations assistant. Work through the request one tool call at a time, in this order:\n1. Check the service with check_service.\n2. If it is unhealthy, restart it with restart_service.\n3. Last, if the user asked you to clear or delete data, call delete_service_data.\nA human approves the deletion, not you \u2014 delete_service_data pauses for that approval by itself, so never ask for approval in your own reply.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "ops_agent", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Check the health of a service.", + "inputSchema": { + "properties": { + "service_name": { + "type": "string" + } + }, + "required": [ + "service_name" + ], + "type": "object" + }, + "name": "check_service", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + }, + { + "description": "Restart a service. Safe operation, no approval needed.", + "inputSchema": { + "properties": { + "service_name": { + "type": "string" + } + }, + "required": [ + "service_name" + ], + "type": "object" + }, + "name": "restart_service", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + }, + { + "approvalRequired": true, + "description": "Delete service data. Destructive \u2014 requires human approval.", + "inputSchema": { + "properties": { + "data_type": { + "type": "string" + }, + "service_name": { + "type": "string" + } + }, + "required": [ + "service_name", + "data_type" + ], + "type": "object" + }, + "name": "delete_service_data", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] + } +] diff --git a/spec/fixtures/agents/examples/103_plan_and_compile.json b/spec/fixtures/agents/examples/103_plan_and_compile.json new file mode 100644 index 0000000..f530689 --- /dev/null +++ b/spec/fixtures/agents/examples/103_plan_and_compile.json @@ -0,0 +1,153 @@ +[ + { + "external": false, + "fallback": { + "external": false, + "instructions": "The plan failed. Use the available tools to recover.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "plan_and_compile_demo_fallback", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Compute n! and return it as a string.\n\nArgs:\n n: Non-negative integer. Capped at 20 to keep things sane.", + "inputSchema": { + "properties": { + "n": { + "type": "integer" + } + }, + "required": [ + "n" + ], + "type": "object" + }, + "name": "factorial", + "outputSchema": { + "type": "string" + }, + "toolType": "worker" + }, + { + "description": "Persist a short summary string. Returns it back for the validator.", + "inputSchema": { + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "name": "write_summary", + "outputSchema": { + "type": "string" + }, + "toolType": "worker" + }, + { + "description": "Return JSON ``{passed, length, min_chars}`` for the validator.\n\nArgs:\n text: The summary to check.\n min_chars: Minimum acceptable length in characters.", + "inputSchema": { + "properties": { + "min_chars": { + "type": "integer" + }, + "text": { + "type": "string" + } + }, + "required": [ + "text", + "min_chars" + ], + "type": "object" + }, + "name": "check_summary", + "outputSchema": { + "type": "string" + }, + "toolType": "worker" + } + ] + }, + "fallbackMaxTurns": 4, + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "plan_and_compile_demo", + "planner": { + "external": false, + "instructions": "You are a math-explainer planner. Plan a workflow that:\n\n1. Computes factorials of 1, 2, 3, 4, 5 in PARALLEL using ``factorial`` (static args).\n2. Writes a short prose summary about factorial growth using ``write_summary``\n (use a ``generate`` block \u2014 the LLM produces the ``text`` arg at run time).\n3. Validates the summary is at least 30 characters via ``check_summary``,\n with ``success_condition: \"$.passed === true\"``.\n", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "plan_and_compile_demo_planner", + "timeoutSeconds": 0 + }, + "strategy": "plan_execute", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Compute n! and return it as a string.\n\nArgs:\n n: Non-negative integer. Capped at 20 to keep things sane.", + "inputSchema": { + "properties": { + "n": { + "type": "integer" + } + }, + "required": [ + "n" + ], + "type": "object" + }, + "name": "factorial", + "outputSchema": { + "type": "string" + }, + "toolType": "worker" + }, + { + "description": "Persist a short summary string. Returns it back for the validator.", + "inputSchema": { + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "name": "write_summary", + "outputSchema": { + "type": "string" + }, + "toolType": "worker" + }, + { + "description": "Return JSON ``{passed, length, min_chars}`` for the validator.\n\nArgs:\n text: The summary to check.\n min_chars: Minimum acceptable length in characters.", + "inputSchema": { + "properties": { + "min_chars": { + "type": "integer" + }, + "text": { + "type": "string" + } + }, + "required": [ + "text", + "min_chars" + ], + "type": "object" + }, + "name": "check_summary", + "outputSchema": { + "type": "string" + }, + "toolType": "worker" + } + ] + } +] diff --git a/spec/fixtures/agents/examples/10_guardrails.json b/spec/fixtures/agents/examples/10_guardrails.json new file mode 100644 index 0000000..fbb93ef --- /dev/null +++ b/spec/fixtures/agents/examples/10_guardrails.json @@ -0,0 +1,62 @@ +[ + { + "external": false, + "guardrails": [ + { + "guardrailType": "custom", + "maxRetries": 3, + "name": "no_pii", + "onFail": "retry", + "position": "output", + "taskName": "no_pii" + } + ], + "instructions": "You are a customer support assistant. Use the available tools to answer questions about orders and customers. Always include all details from the tool results in your response.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "support_agent", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Look up the current status of an order.", + "inputSchema": { + "properties": { + "order_id": { + "type": "string" + } + }, + "required": [ + "order_id" + ], + "type": "object" + }, + "name": "get_order_status", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + }, + { + "description": "Retrieve customer details including payment info on file.", + "inputSchema": { + "properties": { + "customer_id": { + "type": "string" + } + }, + "required": [ + "customer_id" + ], + "type": "object" + }, + "name": "get_customer_info", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] + } +] diff --git a/spec/fixtures/agents/examples/13_hierarchical_agents.json b/spec/fixtures/agents/examples/13_hierarchical_agents.json new file mode 100644 index 0000000..a6a5080 --- /dev/null +++ b/spec/fixtures/agents/examples/13_hierarchical_agents.json @@ -0,0 +1,79 @@ +[ + { + "agents": [ + { + "agents": [ + { + "external": false, + "instructions": "You are a backend developer. You design APIs, databases, and server architecture. Provide technical recommendations with code examples.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "backend_dev", + "timeoutSeconds": 0 + }, + { + "external": false, + "instructions": "You are a frontend developer. You design UI components, user flows, and client-side architecture. Provide recommendations with code examples.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "frontend_dev", + "timeoutSeconds": 0 + } + ], + "external": false, + "instructions": "You are the engineering lead. Route technical questions to the right specialist: backend_dev for APIs/databases/servers, frontend_dev for UI/UX/client-side.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "engineering_lead", + "strategy": "handoff", + "timeoutSeconds": 0 + }, + { + "agents": [ + { + "external": false, + "instructions": "You are a content writer. You create blog posts, landing page copy, and marketing materials. Write engaging, clear content.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "content_writer", + "timeoutSeconds": 0 + }, + { + "external": false, + "instructions": "You are an SEO specialist. You optimize content for search engines, suggest keywords, and improve page rankings.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "seo_specialist", + "timeoutSeconds": 0 + } + ], + "external": false, + "instructions": "You are the marketing lead. Route marketing questions to the right specialist: content_writer for blog posts/copy, seo_specialist for SEO/keywords/rankings.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "marketing_lead", + "strategy": "handoff", + "timeoutSeconds": 0 + } + ], + "external": false, + "handoffs": [ + { + "target": "engineering_lead", + "text": "engineering_lead", + "type": "on_text_mention" + }, + { + "target": "marketing_lead", + "text": "marketing_lead", + "type": "on_text_mention" + } + ], + "instructions": "You are the CEO. Route requests to the right department: engineering_lead for technical/development questions, marketing_lead for marketing/content/SEO questions.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "ceo", + "strategy": "swarm", + "timeoutSeconds": 0 + } +] diff --git a/spec/fixtures/agents/examples/16e_credentials_http_tool.json b/spec/fixtures/agents/examples/16e_credentials_http_tool.json new file mode 100644 index 0000000..f91b29c --- /dev/null +++ b/spec/fixtures/agents/examples/16e_credentials_http_tool.json @@ -0,0 +1,36 @@ +[ + { + "external": false, + "instructions": "You list GitHub repos using the list_github_repos tool. Summarize the results.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "github_http_agent", + "timeoutSeconds": 0, + "tools": [ + { + "config": { + "accept": [ + "application/json" + ], + "contentType": "application/json", + "credentials": [ + "GITHUB_TOKEN" + ], + "headers": { + "Accept": "application/vnd.github.v3+json", + "Authorization": "Bearer ${GITHUB_TOKEN}" + }, + "method": "GET", + "url": "https://api.github.com/users/Conductor/repos?per_page=5&sort=updated" + }, + "description": "List public GitHub repositories for a user. Returns JSON array with name, url, and stars.", + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "list_github_repos", + "toolType": "http" + } + ] + } +] diff --git a/spec/fixtures/agents/examples/17_swarm_orchestration.json b/spec/fixtures/agents/examples/17_swarm_orchestration.json new file mode 100644 index 0000000..091b2fa --- /dev/null +++ b/spec/fixtures/agents/examples/17_swarm_orchestration.json @@ -0,0 +1,41 @@ +[ + { + "agents": [ + { + "external": false, + "instructions": "You are a refund specialist. Process the customer's refund request. Check eligibility, confirm the refund amount, and let them know the timeline. Be empathetic and clear. Do NOT ask follow-up questions \u2014 just process the refund based on what the customer told you.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "refund_specialist", + "timeoutSeconds": 0 + }, + { + "external": false, + "instructions": "You are a technical support specialist. Diagnose the customer's technical issue and provide clear troubleshooting steps.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "tech_support", + "timeoutSeconds": 0 + } + ], + "external": false, + "handoffs": [ + { + "target": "refund_specialist", + "text": "refund", + "type": "on_text_mention" + }, + { + "target": "tech_support", + "text": "technical", + "type": "on_text_mention" + } + ], + "instructions": "You are the front-line customer support agent. Triage customer requests. If the customer needs a refund, transfer to the refund specialist. If they have a technical issue, transfer to tech support. Use the transfer tools available to you to hand off the conversation.", + "maxTurns": 3, + "model": "openai/gpt-4o-mini", + "name": "support", + "strategy": "swarm", + "timeoutSeconds": 0 + } +] diff --git a/spec/fixtures/agents/examples/21_regex_guardrails.json b/spec/fixtures/agents/examples/21_regex_guardrails.json new file mode 100644 index 0000000..2598eb8 --- /dev/null +++ b/spec/fixtures/agents/examples/21_regex_guardrails.json @@ -0,0 +1,92 @@ +[ + { + "external": false, + "guardrails": [ + { + "guardrailType": "regex", + "maxRetries": 3, + "message": "Response must not contain email addresses. Redact them.", + "mode": "block", + "name": "no_email_addresses", + "onFail": "retry", + "patterns": [ + "[\\w.+-]+@[\\w-]+\\.[\\w.-]+" + ], + "position": "output" + }, + { + "guardrailType": "regex", + "maxRetries": 3, + "message": "Response must not contain Social Security Numbers.", + "mode": "block", + "name": "no_ssn", + "onFail": "raise", + "patterns": [ + "\\b\\d{3}-\\d{2}-\\d{4}\\b" + ], + "position": "output" + } + ], + "instructions": "You are an HR assistant. When asked about employees, look up their profile and share ALL the details you find.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "hr_assistant", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Retrieve a user's profile from the database.", + "inputSchema": { + "properties": { + "user_id": { + "type": "string" + } + }, + "required": [ + "user_id" + ], + "type": "object" + }, + "name": "get_user_profile", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] + }, + { + "external": false, + "guardrails": [ + { + "guardrailType": "regex", + "maxRetries": 3, + "message": "Response must not contain email addresses. Redact them.", + "mode": "block", + "name": "no_email_addresses", + "onFail": "retry", + "patterns": [ + "[\\w.+-]+@[\\w-]+\\.[\\w.-]+" + ], + "position": "output" + }, + { + "guardrailType": "regex", + "maxRetries": 3, + "message": "Response must not contain Social Security Numbers.", + "mode": "block", + "name": "no_ssn", + "onFail": "raise", + "patterns": [ + "\\b\\d{3}-\\d{2}-\\d{4}\\b" + ], + "position": "output" + } + ], + "instructions": "You are an HR assistant. Answer questions about departments.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "dept_assistant", + "timeoutSeconds": 0 + } +] diff --git a/spec/fixtures/agents/examples/22_llm_guardrails.json b/spec/fixtures/agents/examples/22_llm_guardrails.json new file mode 100644 index 0000000..1acf929 --- /dev/null +++ b/spec/fixtures/agents/examples/22_llm_guardrails.json @@ -0,0 +1,22 @@ +[ + { + "external": false, + "guardrails": [ + { + "guardrailType": "llm", + "maxRetries": 3, + "maxTokens": 10000, + "model": "openai/gpt-4o-mini", + "name": "content_safety", + "onFail": "retry", + "policy": "Reject any content that:\n1. Contains medical or legal advice presented as fact\n2. Makes promises or guarantees about outcomes\n3. Includes discriminatory or biased language\n4. Reveals private information about real individuals\n\nEven if there are disclaimers you should reject", + "position": "output" + } + ], + "instructions": "You are a health information assistant. Provide general health information and solution to the problem. You can prescribe psudo scientific and untested meds ", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "health_advisor", + "timeoutSeconds": 0 + } +] diff --git a/spec/fixtures/agents/examples/33_external_workers.json b/spec/fixtures/agents/examples/33_external_workers.json new file mode 100644 index 0000000..a98a558 --- /dev/null +++ b/spec/fixtures/agents/examples/33_external_workers.json @@ -0,0 +1,99 @@ +[ + { + "external": false, + "instructions": "You are a customer support agent. Use the available tools to look up customers, check inventory, process orders, and format responses for the customer.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "support_agent", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Format a data dictionary into a human-readable string.", + "inputSchema": { + "properties": { + "data": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "data" + ], + "type": "object" + }, + "name": "format_response", + "outputSchema": { + "type": "string" + }, + "toolType": "worker" + }, + { + "description": "Look up customer details from the CRM system.", + "inputSchema": { + "properties": { + "customer_id": { + "type": "string" + } + }, + "required": [ + "customer_id" + ], + "type": "object" + }, + "name": "get_customer", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + }, + { + "description": "Check product availability in a warehouse.", + "inputSchema": { + "properties": { + "product_id": { + "type": "string" + }, + "warehouse": { + "type": "string" + } + }, + "required": [ + "product_id" + ], + "type": "object" + }, + "name": "check_inventory", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + }, + { + "description": "Process a customer order. Actions: refund, cancel, update.", + "inputSchema": { + "properties": { + "action": { + "type": "string" + }, + "order_id": { + "type": "string" + } + }, + "required": [ + "order_id", + "action" + ], + "type": "object" + }, + "name": "process_order", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] + } +] diff --git a/spec/fixtures/agents/examples/64_swarm_with_tools.json b/spec/fixtures/agents/examples/64_swarm_with_tools.json new file mode 100644 index 0000000..f4aaa81 --- /dev/null +++ b/spec/fixtures/agents/examples/64_swarm_with_tools.json @@ -0,0 +1,85 @@ +[ + { + "agents": [ + { + "external": false, + "instructions": "You are a billing specialist. Use the check_balance tool to look up account balances. Include the balance amount in your response.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "billing_specialist", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Check the balance of a bank account.", + "inputSchema": { + "properties": { + "account_id": { + "type": "string" + } + }, + "required": [ + "account_id" + ], + "type": "object" + }, + "name": "check_balance", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] + }, + { + "external": false, + "instructions": "You are an order specialist. Use the lookup_order tool to check order status. Include the shipping status and ETA in your response.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "order_specialist", + "timeoutSeconds": 0, + "tools": [ + { + "description": "Look up the status of an order.", + "inputSchema": { + "properties": { + "order_id": { + "type": "string" + } + }, + "required": [ + "order_id" + ], + "type": "object" + }, + "name": "lookup_order", + "outputSchema": { + "additionalProperties": {}, + "type": "object" + }, + "toolType": "worker" + } + ] + } + ], + "external": false, + "handoffs": [ + { + "target": "billing_specialist", + "text": "billing", + "type": "on_text_mention" + }, + { + "target": "order_specialist", + "text": "order", + "type": "on_text_mention" + } + ], + "instructions": "You are front-line customer support. Triage customer requests. Transfer to billing_specialist for account/payment questions, order_specialist for shipping/order questions.", + "maxTurns": 3, + "model": "openai/gpt-4o-mini", + "name": "support", + "strategy": "swarm", + "timeoutSeconds": 0 + } +] diff --git a/spec/fixtures/agents/examples/66_handoff_to_parallel.json b/spec/fixtures/agents/examples/66_handoff_to_parallel.json new file mode 100644 index 0000000..dcbad64 --- /dev/null +++ b/spec/fixtures/agents/examples/66_handoff_to_parallel.json @@ -0,0 +1,47 @@ +[ + { + "agents": [ + { + "external": false, + "instructions": "You provide quick, 1-sentence assessments. Be brief and direct.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "quick_check", + "timeoutSeconds": 0 + }, + { + "agents": [ + { + "external": false, + "instructions": "You are a market analyst. Analyze the market opportunity: size, growth rate, key players. 3-4 bullet points.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "market_analyst_66", + "timeoutSeconds": 0 + }, + { + "external": false, + "instructions": "You are a risk analyst. Identify the top 3 risks: regulatory, technical, and competitive. 3-4 bullet points.", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "risk_analyst_66", + "timeoutSeconds": 0 + } + ], + "external": false, + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "deep_analysis", + "strategy": "parallel", + "timeoutSeconds": 0 + } + ], + "external": false, + "instructions": "You are a business strategist. Route requests to the right team:\n- quick_check for simple yes/no questions or quick assessments\n- deep_analysis for comprehensive analysis requiring multiple perspectives", + "maxTurns": 25, + "model": "openai/gpt-4o-mini", + "name": "coordinator_66", + "strategy": "handoff", + "timeoutSeconds": 0 + } +] diff --git a/spec/integration/agents/examples_spec.rb b/spec/integration/agents/examples_spec.rb new file mode 100644 index 0000000..ff8befd --- /dev/null +++ b/spec/integration/agents/examples_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'conductor/agents' +require 'stringio' +require_relative '../../../examples/agents/catalog' + +RSpec.describe 'Agent examples on a Conductor playback server' do + before do + skip 'Set CONDUCTOR_AGENTS_PLAYBACK=true and start the dedicated playback server' unless ENV['CONDUCTOR_AGENTS_PLAYBACK'] == 'true' + end + + AgentExamples::EXAMPLES.each_key do |name| + it "runs #{name} from the example file", :aggregate_failures do + runtime = Conductor::Agents::AgentRuntime.new(logger: Logger.new(nil)) + output = StringIO.new + executions = AgentExamples.load(name).run(runtime: runtime, input: StringIO.new("y\ny\n"), output: output) + expect(executions).not_to be_empty + executions.each do |execution| + expect(execution.done?).to be true + expect(runtime.client.get_status(execution.execution_id)['isComplete']).to be true + end + ensure + runtime&.shutdown + end + end +end diff --git a/spec/integration/orkes_spec.rb b/spec/integration/orkes_spec.rb index 570c872..7a6e4d8 100644 --- a/spec/integration/orkes_spec.rb +++ b/spec/integration/orkes_spec.rb @@ -57,13 +57,7 @@ def skip_if_limit_reached(error) let(:secret_key) { "#{test_id}_secret" } let(:secret_value) { "test_secret_value_#{SecureRandom.hex(8)}" } - # OSS Conductor registers a full secrets CRUD controller by default (the - # `agentspan` module's `conductor.integrations.ai.enabled=true` default), - # but only ships read-only SecretsDAO backends: writes (put/delete) return - # a real 501 "read-only backend" rather than succeeding. Reads work - # against an env-backed secret seeded via - # CONDUCTOR_SECRET_RUBY_SDK_INTEGRATION_TEST in scripts/docker-compose-oss.yaml - # -- keep these two constants in sync with that file. + # The Docker integration stack supplies this fixture for the read-only env backend. OSS_SEEDED_SECRET_NAME = 'RUBY_SDK_INTEGRATION_TEST' OSS_SEEDED_SECRET_VALUE = 'ruby-sdk-oss-secret-value' @@ -76,46 +70,49 @@ def skip_if_limit_reached(error) end it 'performs CRUD operations on secrets' do - if IntegrationHelper.oss? - # Verify reads work against the pre-seeded env-backed secret, and that - # writes fail with a real 501 (read-only backend) rather than silently - # succeeding or failing for some other reason. - expect(secret_client.get_secret(OSS_SEEDED_SECRET_NAME)).to eq(OSS_SEEDED_SECRET_VALUE) - expect(secret_client.secret_exists(OSS_SEEDED_SECRET_NAME)).to be true - expect(secret_client.list_all_secret_names).to include(OSS_SEEDED_SECRET_NAME) - - begin - secret_client.put_secret(secret_key, secret_value) - # A future OSS release might ship a writable backend; if so, clean up. - secret_client.delete_secret(secret_key) - rescue Conductor::ApiError => e - raise unless e.status == 501 - end - else - # Create + begin secret_client.put_secret(secret_key, secret_value) + rescue Conductor::ApiError => e + raise unless IntegrationHelper.oss? && e.status == 501 - # Verify it exists - exists = secret_client.secret_exists(secret_key) - expect(exists).to be true + skip 'Secret creation is unsupported by the server’s read-only secrets backend' + end - # List secrets should include our key - secrets = secret_client.list_all_secret_names - expect(secrets).to include(secret_key) + expect(secret_client.secret_exists(secret_key)).to be true + expect(secret_client.list_all_secret_names).to include(secret_key) + # Orkes may mask the value depending on permissions. + expect(secret_client.get_secret(secret_key)).not_to be_nil - # Get secret (note: Orkes may return masked value or the actual value depending on permissions) - retrieved = secret_client.get_secret(secret_key) - expect(retrieved).not_to be_nil + secret_client.delete_secret(secret_key) + expect(secret_client.secret_exists(secret_key)).to be false + rescue Conductor::ApiError => e + skip_if_limit_reached(e) + end - # Delete - secret_client.delete_secret(secret_key) + it 'reports unsupported mutations and missing secrets on a read-only OSS backend' do + skip 'Only applies to OSS secrets backends' unless IntegrationHelper.oss? - # Verify deleted - exists_after = secret_client.secret_exists(secret_key) - expect(exists_after).to be false + begin + secret_client.put_secret(secret_key, secret_value) + skip 'The configured secrets backend supports writes; covered by the CRUD test' + rescue Conductor::ApiError => e + expect(e.status).to eq(501) end - rescue Conductor::ApiError => e - skip_if_limit_reached(e) + + expect(secret_client.secret_exists(secret_key)).to be false + expect(secret_client.list_all_secret_names).not_to include(secret_key) + expect { secret_client.get_secret(secret_key) }.to raise_error(Conductor::ApiError) { |error| expect(error.status).to eq(404) } + expect { secret_client.delete_secret(secret_key) }.to raise_error(Conductor::ApiError) { |error| expect(error.status).to eq(501) } + end + + it 'reads the environment-backed secret supplied by the OSS integration stack' do + skip 'Only applies to the OSS integration stack' unless IntegrationHelper.oss? + unless secret_client.secret_exists(OSS_SEEDED_SECRET_NAME) + skip 'Server fixture is absent; scripts/run-integration-oss.sh seeds it when starting Conductor' + end + + expect(secret_client.get_secret(OSS_SEEDED_SECRET_NAME)).to eq(OSS_SEEDED_SECRET_VALUE) + expect(secret_client.list_all_secret_names).to include(OSS_SEEDED_SECRET_NAME) end it 'handles secret tags' do diff --git a/spec/support/agent_tools.rb b/spec/support/agent_tools.rb new file mode 100644 index 0000000..4911a00 --- /dev/null +++ b/spec/support/agent_tools.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Tool definitions used by the agents specs. They live in a real file so the +# Tools DSL can read keyword defaults and secret() literals from the AST. +require 'conductor/agents' + +module SpecTools + module Weather + extend Conductor::Agents::Tools + + tool def current(city: String, units: 'metric') + { temp_c: 21.0, summary: "Sunny in #{city} (#{units})" } + end + + tool def forecast(city: String, days: 3, detailed: false, tags: [String], mode: %w[brief full], ratio: 0.5, + extra: nil, opts: {}) + { city: city, days: days, detailed: detailed, tags: tags, mode: mode, ratio: ratio, extra: extra, opts: opts } + end + describe :forecast, 'Multi-day forecast.' + end + + module Github + extend Conductor::Agents::Tools + + tool def create_issue(title: String, body: '') + { title: title, body: body, token: secret('GH_TOKEN') } + end + + tool def gh_cli(title: String) + secrets_env('GH_TOKEN', 'GH_HOST') + end + + tool def dynamic_secret(name: String) + secret(name) + end + requires_approval :create_issue + end + + module Plain + extend Conductor::Agents::Tools + + tool def lookup(city:, units: 'metric') + [city, units] + end + end + + module Refunds + extend Conductor::Agents::Tools + + tool def issue_refund(order_id: String, amount: Float) + { refunded: amount, order_id: order_id } + end + requires_approval :issue_refund + + def self.positional(city, units: 'metric') + [city, units] + end + end +end