diff --git a/.github/workflows/agent-e2e.yml b/.github/workflows/agent-e2e.yml index 2ecd7767..9a936700 100644 --- a/.github/workflows/agent-e2e.yml +++ b/.github/workflows/agent-e2e.yml @@ -17,7 +17,7 @@ concurrency: cancel-in-progress: true env: - AGENTSPAN_VERSION: "0.4.0" # pinned server/CLI release — bump deliberately + AGENTSPAN_VERSION: "0.4.3" # pinned server/CLI release — bump deliberately jobs: agent-e2e: diff --git a/.gitignore b/.gitignore index f60b9c74..09aa5181 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ __pycache__/ *.py[cod] *$py.class +# Example-run artifacts (generated by run_all_examples.py / coding-agent examples) +examples/agents/run_report.html +examples/agents/codingexamples/ + # C extensions *.so diff --git a/AGENTS.md b/AGENT.md similarity index 80% rename from AGENTS.md rename to AGENT.md index 0656ad66..0164a7a4 100644 --- a/AGENTS.md +++ b/AGENT.md @@ -14,33 +14,33 @@ This guide provides a complete blueprint for creating or refactoring Conductor S ``` ┌─────────────────────────────────────────────────┐ -│ Application Layer │ -│ (User's Application Code) │ +│ Application Layer │ +│ (User's Application Code) │ └─────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────┐ -│ High-Level Clients │ -│ (OrkesClients, WorkflowExecutor, Workers) │ +│ High-Level Clients │ +│ (OrkesClients, WorkflowExecutor, Workers) │ └─────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────┐ -│ Domain-Specific Clients │ -│ (TaskClient, WorkflowClient, SecretClient...) │ +│ Domain-Specific Clients │ +│ (TaskClient, WorkflowClient, SecretClient...) │ └─────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────┐ -│ Orkes Implementations │ -│ (OrkesTaskClient, OrkesWorkflowClient...) │ +│ Orkes Implementations │ +│ (OrkesTaskClient, OrkesWorkflowClient...) │ └─────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────┐ -│ Resource API Layer │ -│ (TaskResourceApi, WorkflowResourceApi...) │ +│ Resource API Layer │ +│ (TaskResourceApi, WorkflowResourceApi...) │ └─────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────┐ -│ HTTP/API Client │ -│ (ApiClient, HTTP Transport) │ +│ HTTP/API Client │ +│ (ApiClient, HTTP Transport) │ └─────────────────────────────────────────────────┘ ``` @@ -255,6 +255,84 @@ Abstract Interface (20+ methods): --- +## 🤖 Agent SDK Layer + +An **optional higher-level layer** for building LLM agents on top of the client + +worker SDK. Agents are authored in code but **compiled and executed on the server** +as durable Conductor workflows; an agent's tools are ordinary Conductor **workers** +(reuse the entire Worker Framework, Phase 3). Full language-agnostic spec: +[`docs/design/WORKER_SDK_IMPLEMENTATION_GUIDE.md` §25](docs/design/WORKER_SDK_IMPLEMENTATION_GUIDE.md#25-agent-sdk-layer-building-agents-on-the-worker-sdk). + +### Two Planes + +``` + ┌───────────────────────────────┐ + │ AgentRuntime │ (facade) + └───────────────────────────────┘ + │ │ + control plane │ │ worker plane + ▼ ▼ + ┌───────────────────────┐ ┌────────────────────────────┐ + │ AgentClient │ │ WorkerManager/TaskHandler │ + │ /agent/* over the │ │ (Phase 3 workers serve │ + │ standard ApiClient │ │ the agent's tools) │ + └───────────────────────┘ └────────────────────────────┘ +``` + +### Core Components + +| Component | Role | +|---|---| +| `AgentRuntime` | User-facing facade: `run`/`start`/`stream`/`deploy`/`serve`/`plan` (+ async). Composes a `Configuration`, `AgentConfig`, `AgentClient`, and a `WorkerManager`. | +| `AgentClient` (interface) + `OrkesAgentClient` | Control plane (compile/deploy/start/status/stream/respond/stop/signal). Built on the **standard `ApiClient`** — reuses its token management; no separate token cache. Same interface + Orkes-impl pattern as the domain clients (Phase 2). | +| `AgentConfig` | Agent-runtime **behaviour** only (worker pool, liveness, streaming, integration auto-register). Connection/auth/log level come from `Configuration`. | +| `RunSettings` | Per-run LLM overrides (`model`/`temperature`/`max_tokens`/…) for a single `run`/`start`; applied to the serialized agent config before start. | +| Framework adapters | Run agents authored in LangChain / LangGraph / OpenAI Agents SDK / Claude Agent SDK on the durable runtime as spawn-safe passthrough workers. | + +### Verb Contract + +| Method | Blocks | Returns | Starts workers | Registers on server | +|---|---|---|---|---| +| `run` | yes | result (output + ids) | yes | via start | +| `start` | no | handle (`execution_id`) | yes | via start | +| `deploy` | yes | deployment info | no | yes | +| `serve` | yes (until signal) | — | yes | yes (`serve` = `deploy` + serve) | +| `plan` | yes | workflow def | no | no | + +### Key Principles + +- **Single token authority** — control plane *and* worker-side agent posts reuse the one `ApiClient`'s mint/cache/TTL-refresh/401-retry; never a parallel token cache. In spawned workers, rebuild + cache a client per `(server_url, auth_key)`. +- **Spawn-safety** — tool/worker callables must be module-level (importable/picklable), never `` closures; entry scripts use a main-module guard. +- **Credentials** — declared per tool; the server resolves + delivers them on wire-only `Task.runtimeMetadata`; injected into the worker env for the call, never read from ambient env. +- **Config single-source** — connection/auth/log level live on `Configuration`; `AgentConfig` is behaviour-only. + +### Package Additions + +``` +src/conductor/ +├── client/ +│ ├── agent_client.{ext} # AgentClient interface +│ └── orkes/orkes_agent_client.{ext} # OrkesAgentClient (on ApiClient) +└── ai/agents/ # agent authoring + runtime + ├── agent.{ext}, run_settings.{ext} + ├── runtime/ (runtime, config, worker_manager) + ├── _internal/agent_http.{ext} # single token authority for /agent/* posts + └── frameworks/ (langchain, langgraph, claude_agent_sdk, …) +``` + +### Agent-Layer Checklist + +- [ ] `AgentClient` interface + Orkes impl on the standard `ApiClient` (sync + async; SSE reuses the client's auth header) +- [ ] `AgentRuntime` facade with the exact verb contract above +- [ ] `AgentConfig` behaviour-only; connection/auth/log level from `Configuration` +- [ ] `RunSettings` per-run LLM overrides +- [ ] Single token authority across control plane + worker-side posts +- [ ] Tool workers reuse the Worker Framework (Phase 3); credentials via `runtimeMetadata` +- [ ] Framework adapters as spawn-safe passthrough workers +- [ ] Liveness monitor for stateful runs + +--- + ## 🌐 Language-Specific Implementation ### Java Implementation diff --git a/README.md b/README.md index 3ad3f912..6daa4729 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,20 @@ If you find [Conductor](https://github.com/conductor-oss/conductor) useful, plea ## Start Conductor Server -If you don't already have a Conductor server running, pick one: +#### Conductor CLI +```shell +# Installs conductor cli +npm install -g @conductor-oss/conductor-cli + +# Start the open source conductor server on port 8080 +conductor server start +# see conductor server --help for all the available commands +``` + -**Docker Compose (recommended, includes UI):** +Alternatively, if you want to use docker + +**Docker Compose** ```shell docker run -p 8080:8080 conductoross/conductor:latest @@ -48,15 +59,6 @@ The UI will be available at `http://localhost:8080` and the API at `http://local curl -sSL https://raw.githubusercontent.com/conductor-oss/conductor/main/conductor_server.sh | sh ``` -**Conductor CLI** -```shell -# Installs conductor cli -npm install -g @conductor-oss/conductor-cli - -# Start the open source conductor server -conductor server start -# see conductor server --help for all the available commands -``` ## Install the SDK @@ -306,7 +308,7 @@ def train_model(dataset_id: str) -> dict: return {'model_id': model.id, 'accuracy': model.accuracy} ``` -Disabled by default. Enable per-worker via decorator, constructor, or environment variable (`conductor_worker__lease_extend_enabled=true`). See [LEASE_EXTENSION.md](LEASE_EXTENSION.md) for the full guide. +Disabled by default. Enable per-worker via decorator, constructor, or environment variable (`conductor_worker__lease_extend_enabled=true`). See [LEASE_EXTENSION.md](docs/LEASE_EXTENSION.md) for the full guide. ### Monitoring with Metrics @@ -494,7 +496,7 @@ End-to-end examples covering all APIs for each domain: | [Worker Design](docs/design/WORKER_DESIGN.md) | Architecture: AsyncTaskRunner vs TaskRunner, discovery, lifecycle | | [Worker Guide](docs/WORKER.md) | All worker patterns (function, class, annotation, async) | | [Worker Configuration](WORKER_CONFIGURATION.md) | Hierarchical environment variable configuration | -| [Lease Extension](LEASE_EXTENSION.md) | Automatic heartbeat for long-running tasks | +| [Lease Extension](docs/LEASE_EXTENSION.md) | Automatic heartbeat for long-running tasks | | [Workflow Management](docs/WORKFLOW.md) | Start, pause, resume, terminate, retry, search | | [Workflow Testing](docs/WORKFLOW_TESTING.md) | Unit testing with mock outputs | | [Task Management](docs/TASK_MANAGEMENT.md) | Task operations | diff --git a/SDK_UPDATE_v1.md b/SDK_UPDATE_v1.md new file mode 100644 index 00000000..99f8afd4 --- /dev/null +++ b/SDK_UPDATE_v1.md @@ -0,0 +1,326 @@ +# SDK Update v1 — Agent Layer: Components, Method Contracts, and Bug Fixes + +**Source:** Python SDK branch `feat/agent-client-worker-credentials` (PR conductor-oss/python-sdk#421) +**Audience:** Developers and coding agents updating the **Java / Go / C# / JavaScript-TypeScript** Conductor SDKs. +**Purpose:** A single reference describing (1) how the agent-layer components are designed and used — `ApiClient`, `AgentClient`, `AgentRuntime`, `AgentConfig`, `RunSettings` — (2) the exact contracts of `run` / `start` / `deploy` / `serve`, and (3) **every bug fix** on this branch, with root cause and port guidance. + +> Companion document: `docs/design/AGENT_SDK_PORTING_SPEC.md` — the same changes expressed as numbered requirements (R1–R13) with acceptance criteria and a test matrix. Use *this* document to understand the design and the fixes; use the spec to drive implementation. + +Code snippets below are the Python reference implementation; port the contracts, not the syntax. + +--- + +## Part 1 — Components + +### 1.1 `ApiClient` — the single token authority + +The `ApiClient` (and its async twin) is the **only** component in the SDK that performs authentication. Everything else — generated resource APIs, domain clients, the agent control plane, and worker-side HTTP — reuses it. + +**What it owns:** + +| Capability | Behavior | +|---|---| +| Token mint | `POST {host}/token` with `{"keyId": …, "keySecret": …}` → `{"token": ""}` | +| Token cache | Token + mint timestamp held on the `Configuration`; shared by every client built from it | +| TTL refresh | Before injecting the header, if the token is older than the TTL, re-mint proactively | +| 401 retry | Any `call_api` that fails with an auth error force-refreshes the token and retries exactly once | +| Header | `X-Authorization: ` on every request (except `/token` itself); no header for anonymous servers | +| Open-server detection | A 404 from `/token` = auth-disabled server → operate anonymously | + +**Generic call surface** — arbitrary endpoints (used by the agent layer for `/agent/*`): + +```python +api_client.call_api( + "/agent/start", "POST", # resource path is relative to Configuration.host, + {}, [], headers, # which already ends in /api + body=payload, + response_type="object", # None => fire-and-forget (executes, returns None) + auth_settings=["api_key"], + _return_http_data_only=True, +) +``` + +**New in this branch — public auth-header accessor** (for transports that cannot go through `call_api`, e.g. SSE): + +```python +api_client.get_authentication_headers() +# -> {"header": {"X-Authorization": ""}} or None (anonymous) +# TTL-aware: renews the token if expired before returning. +``` + +**Rule for all SDKs:** never mint or cache a token anywhere else. If a component needs auth, give it an `ApiClient` (or this accessor). See bug fixes #3 and #12. + +--- + +### 1.2 `AgentClient` — the agent control plane + +A new **domain client** following the SDK's established interface + Orkes-implementation pattern (like `WorkflowClient` / `OrkesWorkflowClient`). + +``` +AgentClient (interface) ←implements— OrkesAgentClient (built on OrkesBaseClient + ApiClient) + └── created via OrkesClients.get_agent_client() +``` + +**Methods** (each has a sync and an `*_async` variant): + +| Method | HTTP | Notes | +|---|---|---| +| `start_agent(payload)` | `POST /agent/start` | Server compiles + registers + starts in one call; returns `{executionId, requiredWorkers?}` | +| `deploy_agent(payload)` | `POST /agent/deploy` | Register only; returns `{agentName}` | +| `compile_agent(payload)` | `POST /agent/compile` | Returns the compiled workflow definition | +| `get_status(execution_id)` | `GET /agent/{id}/status` | | +| `get_execution(execution_id)` | `GET /agent/execution/{id}` | | +| `list_executions(params?)` | `GET /agent/executions` | Query params | +| `respond(execution_id, body)` | `POST /agent/{id}/respond` | Human-in-the-loop answer | +| `stop(execution_id)` | `POST /agent/{id}/stop` | Graceful stop | +| `signal(execution_id, message)` | `POST /agent/{id}/signal` | Inject persistent context | +| `stream_sse(execution_id, last_event_id?)` | `GET /agent/stream/{id}` | SSE event generator | +| `close()` | — | Release async transports | + +**Implementation rules:** +- All non-streaming methods delegate to `api_client.call_api(...)` → token management inherited, zero token code in this class. +- **SSE** uses a streaming HTTP transport but sets its auth header from `api_client.get_authentication_headers()` on every (re)connect. Sends `Accept: text/event-stream` and `Last-Event-ID` on reconnect. Raises `SSEUnavailableError` when the server has no streaming (callers fall back to status polling). +- **Error mapping:** transport `ApiException` → `AgentNotFoundError` (404) / `AgentAPIError` (everything else), both carrying status, body, url. + +**Usage:** + +```python +clients = OrkesClients(configuration=Configuration()) +agent_client = clients.get_agent_client() +data = agent_client.start_agent({"agentConfig": cfg, "prompt": "hi", "sessionId": "", "media": []}) +``` + +--- + +### 1.3 `AgentRuntime` — the facade + +The single user-facing entry point for authoring-and-running agents. + +**Constructor contract:** + +```python +AgentRuntime(configuration: Optional[Configuration] = None, *, settings: Optional[AgentConfig] = None) +``` + +- `configuration` — the standard SDK `Configuration`; **sole source** of host, auth, and log level. Defaults to env-resolved `Configuration()`. +- `settings` — optional `AgentConfig` (behaviour knobs only, §1.4). Defaults to `AgentConfig.from_env()`. + +**Composition** (all built from ONE `OrkesClients(configuration)`, hence one token cache): + +``` +AgentRuntime + ├── _agent_client = clients.get_agent_client() # control plane (§1.2); exposed as runtime.client + ├── _workflow_client / _task_client # status polling, HITL updates, messaging + └── _worker_manager = WorkerManager(configuration, …) # wraps TaskHandler/TaskRunner; serves tool workers +``` + +- Context manager, sync and async (`with` / `async with`). +- Every `/agent/*` operation goes through `_agent_client`. The runtime holds **no** URL builders, HTTP transports, or token helpers of its own. +- There is **no server auto-start**: the runtime never probes for or launches a server (removed on this branch; a missing server surfaces as a connection error). + +**Execution model:** an agent is authored locally but **compiled and executed on the server** as a Conductor workflow. The agent's tools are ordinary Conductor **workers** served by the runtime's `WorkerManager` — the standard poll/execute/update loop, unchanged. + +--- + +### 1.4 `AgentConfig` — agent-runtime behaviour ONLY + +Reduced on this branch to behaviour knobs. **Connection, auth, and log level are NOT here** — they live on `Configuration` (see bug fix #8). + +| Field | Type | Default | Env var | +|---|---|---|---| +| `worker_poll_interval_ms` | int | 100 | `AGENTSPAN_WORKER_POLL_INTERVAL` | +| `worker_thread_count` | int | 1 | `AGENTSPAN_WORKER_THREADS` | +| `auto_start_workers` | bool | true | `AGENTSPAN_AUTO_START_WORKERS` | +| `daemon_workers` | bool | true | `AGENTSPAN_DAEMON_WORKERS` | +| `auto_register_integrations` | bool | false | `AGENTSPAN_INTEGRATIONS_AUTO_REGISTER` | +| `streaming_enabled` | bool | true | `AGENTSPAN_STREAMING_ENABLED` | +| `liveness_enabled` | bool | true | `AGENTSPAN_LIVENESS_ENABLED` | +| `liveness_stall_seconds` | float | 30.0 | `AGENTSPAN_LIVENESS_STALL_SECONDS` | +| `liveness_check_interval_seconds` | float | 10.0 | `AGENTSPAN_LIVENESS_CHECK_INTERVAL_SECONDS` | + +**Removed fields** (do not port): `server_url`, `api_key`, `auth_key`, `auth_secret` (+ `api_secret` alias), `llm_retry_count` (dead — never read), `secret_strict_mode` (dead — fail-closed credentials are unconditional), `log_level` (moved to `Configuration`), `auto_start_server`. + +**`Configuration` env resolution** (for reference — this is where connection/auth/log level live): +- Host: `CONDUCTOR_SERVER_URL` → `AGENTSPAN_SERVER_URL` → `http://localhost:8080/api` +- Auth: `CONDUCTOR_AUTH_KEY` / `CONDUCTOR_AUTH_SECRET` +- Log level: explicit param → `CONDUCTOR_LOG_LEVEL` → `AGENTSPAN_LOG_LEVEL` → derived from `debug` + +--- + +### 1.5 `RunSettings` — per-run LLM overrides + +New on this branch. Lets one invocation override the LLM settings baked into an `Agent`, without constructing a new agent. + +```python +from conductor.ai.agents import RunSettings + +result = runtime.run( + agent, "Summarize this.", + run_settings=RunSettings(model="anthropic/claude-sonnet-4-6", temperature=0.0, max_tokens=512), +) +``` + +| Field | Overrides `agentConfig` wire key | +|---|---| +| `model` | `model` | +| `temperature` | `temperature` | +| `max_tokens` | `maxTokens` | +| `reasoning_effort` | `reasoningEffort` | +| `thinking_budget_tokens` | `thinkingConfig = {"enabled": true, "budgetTokens": n}` | + +**Semantics (all MUST hold in every SDK):** +- Applied to the **serialized root `agentConfig`** after serialization, immediately before `start_agent` — pure SDK-side payload mutation; no new server field. Because start = compile+register+start atomically, the override flows into the compiled LLM tasks. +- **Null-check, not truthiness**: only fields that are *set* override — `temperature=0.0` and `max_tokens=0` are valid overrides and must apply. +- Root-only: sub-agents keep their own per-agent settings (no cascade). +- Accepted by `run`, `start`, and their async variants (and module-level convenience wrappers). A plain map with the same keys is also accepted; unknown keys are an error. +- `top_p` is deliberately absent — it does not exist in the agentConfig contract. + +--- + +## Part 2 — Method contracts + +The exact semantics of the runtime verbs. Implementations must match this table: + +| Method | Blocks | Returns | Starts tool workers | Registers agent on server | Executes | +|---|---|---|---|---|---| +| `run(agent, prompt, …)` | yes — polls to completion | **result object** (`output`, `status`, ids, token usage) | **yes** | via start (one server call compiles + registers + starts) | yes | +| `start(agent, prompt, …)` | no | **handle object** (`execution_id`, `join()`, `stop()`, `stream()`) | **yes** | via start | yes | +| `deploy(*agents, packages=?, schedules=?)` | yes | list of deployment info | **no** | **yes** — `POST /agent/deploy` per agent | no | +| `serve(*agents, packages=?, blocking=true)` | yes, until SIGINT/SIGTERM (`blocking=false` returns) | void | **yes** — registers + polls | **yes** — deploys each agent first (`serve` = `deploy` + serve) | no | +| `plan(agent)` | yes | compiled workflow def | no | no | no | +| `stream(agent, prompt, …)` | iterates | event stream | yes | via start | yes | + +### `run` — start, wait, return the result +`run(agent, prompt, *, version?, media?, session_id?, idempotency_key?, on_event?, timeout?, credentials?, context?, run_settings?) -> AgentResult` +Serializes the agent (+ `run_settings` overrides), calls `start_agent`, starts the required tool workers, then polls status to a terminal state. Returns the rich result — the output is `result.output`, **not** a bare value. `credentials=[names]` asks the server to resolve those secrets for this run. + +### `start` — fire-and-forget +`start(agent, prompt, *, version?, media?, session_id?, idempotency_key?, context?, run_settings?) -> AgentHandle` +Same start flow, but returns immediately with a handle: `handle.execution_id`, `handle.join(timeout)`, `handle.stop()`, streaming, HITL helpers. Also starts the tool workers (scheduled tool tasks must have someone to execute them). The first argument accepts an `Agent` object **or** an agent name string (with optional `version=`) for pre-deployed agents. + +### `deploy` — CI/CD registration, nothing runs +`deploy(*agents, packages=None, schedules=UNSET) -> list[DeploymentInfo]` +For each agent (passed directly or discovered from `packages`), detect the framework and call `deploy_agent` — the server compiles and registers the workflow + task definitions. **No local workers are registered or started, nothing executes.** Optional `schedules=` reconciles cron schedules for a single agent. + +### `serve` — register the agent AND serve its workers +`serve(*agents, packages=None, blocking=True) -> None` +Per agent: (1) **deploy it to the server** (same helper `deploy` uses — new on this branch, see bug fix #7), (2) register its local tool workers (task defs registered with overwrite semantics — idempotent with step 1), then start the worker manager polling. With `blocking=True`, installs SIGINT/SIGTERM handlers and blocks until interrupted; `blocking=False` returns after startup (embedding, tests). + +### Shared start flow (pseudocode) + +``` +config_json = serialize(agent) +if run_settings: config_json.update(run_settings.to_config_overrides()) +payload = {agentConfig: config_json, prompt, sessionId: session_id ?? "", media: media ?? []} ++ optional keys only when set: context, idempotencyKey, timeoutSeconds, credentials, runId, static_plan +data = agent_client.start_agent(payload) +execution_id, required_workers = data.executionId, data.requiredWorkers? +prepare_workers(agent, required_workers) # WorkerManager start — serve the tools +``` + +--- + +## Part 3 — Bug fixes on this branch (complete list) + +Each fix lists symptom → root cause → fix → **Port** (what other SDKs must do). + +### SDK bugs + +**#1 — Worker credentials failed on secured hosts (`Required credentials not found: GH_TOKEN. No execution token available`)** +*Cause:* two-fold. The credentials fetcher authenticated with `Authorization: Bearer ` — sending the raw key id as if it were a token instead of minting a JWT for `X-Authorization`. And the whole delivery design depended on a per-execution token + `POST /workers/secrets` fetch that stateless tool workers often didn't have. +*Fix:* the delivery contract was replaced entirely (see #2). The interim header fix (mint via `POST /token`, send `X-Authorization`) applies to any custom transport an SDK still has. +*Port:* never send key ids as bearer tokens; all auth goes through the ApiClient contract (§1.1). + +**#2 — Credential delivery redesigned: `runtimeMetadata` (fail-closed)** +*Cause:* the execution-token/`/workers/secrets` path was racy, added a second auth path, and broke for stateless workers. +*Fix:* new wire contract. `TaskDef.runtimeMetadata: string[]` declares the secret **names** a task type needs — the SDK stamps it at tool registration, and because worker registration uses overwrite semantics, it **re-stamps on every registration** (a sub-bug here: a plain re-register used to wipe the server-side value). At poll time the server resolves the names and delivers the **values** on the wire-only `Task.runtimeMetadata: map` (never persisted). The worker injects them for that call only and **fails** (`CredentialNotFoundError`) if a declared name wasn't delivered — it never falls back to the ambient process environment. +*Port:* add both model fields (JSON name `runtimeMetadata`), stamp on registration, resolve fail-closed at dispatch, delete the fetcher. Requires a server that persists/delivers runtimeMetadata (conductor-oss PR #1255); capability-probe in integration tests. + +**#3 — Parallel token cache in framework workers** +*Symptom:* worker processes held **two token authorities** — `ApiClient`'s and a standalone `token_utils` mint/cache used by raw `requests.post` calls to `/agent/*` (event push, tracking workflows, task injection/completion) — with separate TTL/refresh behavior. +*Fix:* new internal `agent_http` helper: an `ApiClient` **cached per `(server_url, auth_key)`**, reconstructed inside the worker process from plain strings (the constructor mints a token, so caching is mandatory), and `agent_post(path, body, read_response)` on `call_api`. Fire-and-forget callers swallow errors (return null); readers get the parsed object or null. Task-progress updates moved from raw `requests` to the native task client (`update_task`, `IN_PROGRESS`) on the **same** cached client. The standalone mint/cache was deleted (only a JWT-`exp` decoder remains). +*Port:* one `POST /token` code path in the whole SDK. Worker-side posts go through a cached authenticated client. + +**#4 — CLI tools worker was not spawn-safe (`SpawnSafetyError: worker 'run_command' is not spawn-safe`)** +*Cause:* the auto-generated `run_command` tool was a `` closure; spawned worker processes cannot import it. +*Fix:* a module-level callable class (`_CliCommandRunner`) holding plain config (allowed commands, timeout, cwd) — pickles by value. +*Port:* any auto-generated worker must be a top-level callable/class, never a closure. + +**#5 — `GPTAssistantAgent` was not spawn-safe** +*Cause:* its `call_assistant` tool was a closure over `self` inside `__init__`. +*Fix:* module-level `_AssistantCall` callable class with plain-data fields (assistant id, api key, model, instructions); the agent method delegates to it. +*Port:* same invariant as #4 for any built-in agent types. + +**#6 — `AttributeError: 'AgentConfig' object has no attribute 'liveness_stall_seconds'`** +*Cause:* the result/handle code started a `ServerLivenessMonitor` for stateful runs and read three `AgentConfig` fields that were never defined. +*Fix:* added `liveness_enabled` / `liveness_stall_seconds` (30.0) / `liveness_check_interval_seconds` (10.0) with env parsing. The monitor polls the workflow; if a task has had **no polls** for the stall window (worker died), blocking `join()`/`result()` raise `WorkerStallError` instead of hanging forever. +*Port:* implement the liveness monitor + config fields together; never ship a reader without its config. + +**#7 — `serve` did not register the agent on the server** +*Symptom:* `serve` only registered local worker task defs and polled; the agent's workflow definition was never registered, so `serve` alone couldn't make an agent runnable — users had to know to call `deploy` first. +*Fix:* `serve` now deploys each agent (same internal helper as `deploy`) before registering/starting its workers — `serve` = `deploy` + serve. Idempotent with overwrite task-def registration; same ordering `run` already used. +*Port:* add the deploy step at the top of serve's per-agent loop, covering native and framework agents. + +**#8 — `AgentConfig` duplicated connection/auth/log settings (two sources of truth)** +*Symptom:* `AgentConfig.server_url/auth_key/auth_secret/api_key` could disagree with `Configuration`; `llm_retry_count` and `secret_strict_mode` were **dead** (documented behavior that didn't exist — `secret_strict_mode` claimed to control an env fallback that the fail-closed resolver never had). +*Fix:* removed all of them (§1.4). Connection/auth from `Configuration` only; `configure()` convenience now takes a `Configuration` for connection. +*Port:* keep agent settings behaviour-only; delete dead flags rather than documenting them. + +**#9 — Log level only applied to some loggers, configured in the wrong place** +*Cause:* `AgentConfig.log_level` was applied to the agent loggers only; the rest of the SDK had a separate debug-derived level on `Configuration`. +*Fix:* `Configuration` gained a settable `log_level` (param + `CONDUCTOR_LOG_LEVEL` → `AGENTSPAN_LOG_LEVEL` env); the agent runtime reads it from there. One SDK-wide setting. +*Port:* single log-level knob on the configuration object. + +**#10 — Agent server auto-start removed** +*Symptom:* the SDK probed whether an agent server was running and could launch one — masking misconfiguration, surprising in CI, and coupling the SDK to a server distribution. +*Fix:* all detection/launch logic deleted (`runtime/server.py`, `auto_start_server` flag, `AGENTSPAN_AUTO_START_SERVER` env). A missing server is a connection error. +*Port:* do not auto-start servers from the SDK. + +**#11 — Swarm transfers: multiple transfer calls silently dropped; hand-off message lost** +*Symptom:* when the LLM emitted multiple `*_transfer_to_*` calls in one turn, only the first mattered and the rest vanished with no trace; the transfer tool's `message` argument (the hand-off note for the receiving agent) was discarded. +*Fix:* `check_transfer` is now explicitly **first-wins**: it returns `{is_transfer, transfer_to, transfer_message}` and, when extra transfers were emitted, logs a warning and surfaces them as `dropped_transfers` in the task output. The transfer no-op tool now accepts and echoes `message` (schema includes `message: str`), and the server records the hand-off as `[agent -> target]: ` in the conversation. +*Port:* transfer detection must carry the message and make dropped transfers visible — never silently discard model intent. + +**#12 — SSE streaming minted its own token** +*Cause:* the old streaming transport (and the bespoke `AgentApiClient`) had independent JWT minting. +*Fix:* superseded by `OrkesAgentClient.stream_sse`, which borrows the header from `api_client.get_authentication_headers()` per (re)connect (new public accessor — §1.1). +*Port:* streaming reuses the client token; add the accessor. + +### Deleted components (consequence of the fixes) + +| Deleted | Replaced by | +|---|---| +| `client/ai/agent_api_client.py` (bespoke transport, own JWT) | `OrkesAgentClient` on `ApiClient` | +| `runtime/http_client.py` (DX wrapper client) | `runtime.client` → `AgentClient` | +| `runtime/credentials/fetcher.py` (execution-token fetch) | `runtimeMetadata` contract (#2) | +| `runtime/server.py` (server auto-start) | — (#10) | +| `token_utils` mint/cache | `agent_http` on cached `ApiClient` (#3) | + +### Example-level fixes (Python-specific, but the invariants generalize) + +- **Missing main-module guards** (`75/76/82/83/84_*.py`): stateful examples ran orchestration at module top level; spawned worker processes re-imported the module and re-ran it (multiprocessing "safe importing of main module" error / hangs). Fixed with `if __name__ == "__main__":` guards. *Invariant: entry scripts must be safely re-importable wherever workers are separate processes.* +- **Spawn-unsafe example workers** (`82`, `92`, `16g`): tools defined as closures over the runtime or inside factory functions → hoisted to module level; `82` also moved to an **env-var-shared IPC directory** (a per-import temp dir gave every spawned worker a different directory) and rebuilds its workflow client inside the worker from `Configuration()`. +- **Missing `import sys`** (`72`, `73`): `NameError` at entry. +- **Hardcoded server URLs** (`hello_world_schedule*, settings`): now env-resolved (`CONDUCTOR_SERVER_URL` → `AGENTSPAN_SERVER_URL`). +- **Unavailable model id** (`58_scatter_gather`): hardcoded model replaced with the shared settings default. + +### Test/e2e hardening (port the patterns) + +- **Capability probe:** credential e2e tests probe whether the server persists `TaskDef.runtimeMetadata` and skip when it doesn't (older servers) — instead of failing. +- **Determinism:** credential suites use a per-test (function-scoped) runtime so worker task defs are re-registered per test — a cached module-scoped runtime masked registration regressions (verified by mutation testing). +- **Env isolation:** tests that assert "no auth configured" clear ambient `CONDUCTOR_AUTH_*` env vars (CI sets them). +- **Client-layer tests without mocks:** `AgentClient`/`agent_http` tests run against an in-process HTTP server counting `/token` mints and capturing headers — the mint-once assertion is the guard against reintroducing per-call minting. + +--- + +## Porting order + +1. `ApiClient` accessor + `Configuration` env/log changes (§1.1, #9) +2. `AgentClient` + factory; `AgentRuntime` on it; delete bespoke transports + auto-start (§1.2–1.3, #10, #12) +3. Credentials via `runtimeMetadata` (#1, #2) +4. Worker-side single token authority (#3) +5. `RunSettings` + verb contract incl. `serve` = deploy + serve (§1.5, Part 2, #7) +6. Spawn-safety/registration invariants, liveness, swarm transfer fixes (#4–#6, #11) +7. Deletion sweep + test patterns (tables above) + +Definition of done and per-requirement acceptance criteria: see `docs/design/AGENT_SDK_PORTING_SPEC.md`. diff --git a/LEASE_EXTENSION.md b/docs/LEASE_EXTENSION.md similarity index 97% rename from LEASE_EXTENSION.md rename to docs/LEASE_EXTENSION.md index 7949bb44..e18e1fc0 100644 --- a/LEASE_EXTENSION.md +++ b/docs/LEASE_EXTENSION.md @@ -128,7 +128,7 @@ If a heartbeat API call fails, the SDK retries up to 3 times with backoff (`1s`, ## Example -See [examples/lease_extension_example.py](examples/lease_extension_example.py) for a complete runnable example that: +See [examples/lease_extension_example.py](../examples/lease_extension_example.py) for a complete runnable example that: - Defines a long-running worker with `lease_extend_enabled=True` - Creates a workflow with a short `responseTimeoutSeconds` - Runs the workflow and proves the task completes despite sleeping longer than the timeout diff --git a/docs/WORKER.md b/docs/WORKER.md index 18f30bfe..68d62458 100644 --- a/docs/WORKER.md +++ b/docs/WORKER.md @@ -610,7 +610,7 @@ class SimpleCppWorker(WorkerInterface): ## Long-Running Tasks and Lease Extension -For tasks that take longer than the configured `responseTimeoutSeconds`, the SDK provides automatic lease extension to prevent timeouts. See the comprehensive [Lease Extension Guide](../LEASE_EXTENSION.md) for: +For tasks that take longer than the configured `responseTimeoutSeconds`, the SDK provides automatic lease extension to prevent timeouts. See the comprehensive [Lease Extension Guide](LEASE_EXTENSION.md) for: - How lease extension works - Automatic vs manual control diff --git a/docs/agents/README.md b/docs/agents/README.md index 816655b0..0d798289 100644 --- a/docs/agents/README.md +++ b/docs/agents/README.md @@ -1,9 +1,9 @@ -# Agentspan Python SDK +# Conductor Agent Python SDK > Ships as part of [`conductor-python`](https://pypi.org/project/conductor-python/) — install with the `agents` extra > (`pip install 'conductor-python[agents]'`) — you're in the right place. -Long-running, dynamic plan-execute, and event-driven AI agents in Python. You write plain Python; Agentspan compiles your agent into a Conductor workflow that runs on a server — with automatic retries, durable state, human-in-the-loop pauses, streaming, scheduling, dynamic plan-execute, and full execution history. +Long-running, dynamic plan-execute, and event-driven AI agents in Python. You write plain Python; Conductor Agent compiles your agent into a Conductor workflow that runs on a server — with automatic retries, durable state, human-in-the-loop pauses, streaming, scheduling, dynamic plan-execute, and full execution history. ```python from conductor.ai.agents import Agent, AgentRuntime diff --git a/docs/agents/advanced.md b/docs/agents/advanced.md index 248341ed..807c131b 100644 --- a/docs/agents/advanced.md +++ b/docs/agents/advanced.md @@ -12,31 +12,34 @@ ## Runtime init and config `AgentRuntime` is the entry point. Use it as a context manager so workers shut down -cleanly. Config comes from `AgentConfig.from_env()` by default, or pass overrides. +cleanly. Server connection comes from the standard Conductor `Configuration` — +the same object every other client uses — which resolves `CONDUCTOR_SERVER_URL` +(falling back to `AGENTSPAN_SERVER_URL`) and `CONDUCTOR_AUTH_KEY`/`CONDUCTOR_AUTH_SECRET` +from the environment when not passed explicitly. ```python from conductor.ai.agents import AgentRuntime, AgentConfig +from conductor.client.configuration.configuration import Configuration -# From env (AGENTSPAN_SERVER_URL etc.) +# From env (CONDUCTOR_SERVER_URL → AGENTSPAN_SERVER_URL, CONDUCTOR_AUTH_*) with AgentRuntime() as runtime: runtime.run(agent, "hi") -# Explicit kwargs -with AgentRuntime(server_url="https://prod:8080/api", - api_key="...") as runtime: +# Explicit Configuration +with AgentRuntime(Configuration(server_api_url="https://prod:8080/api")) as runtime: ... -# Or an AgentConfig -config = AgentConfig.from_env() -config.auto_start_server = False -with AgentRuntime(config=config) as runtime: +# Runtime behaviour knobs (workers, streaming, log level) via AgentConfig +settings = AgentConfig.from_env() +settings.log_level = "DEBUG" +with AgentRuntime(settings=settings) as runtime: ... ``` `AgentConfig` is a dataclass; `from_env()` reads the `AGENTSPAN_*` environment variables (full list in [Getting started](getting-started.md#environment-variables)). -The Conductor `Configuration` object underneath is built from `server_url` and the -auth fields (`api_key`, or `auth_key`/`auth_secret`). +It carries runtime *behaviour* settings only — server connection always comes from +the `Configuration`. ### Module-level convenience functions @@ -44,8 +47,10 @@ For one-off scripts, top-level functions use a shared singleton runtime: ```python import conductor.ai.agents as ag +from conductor.client.configuration.configuration import Configuration -ag.configure(server_url="https://prod:8080/api", auto_start_server=False) # before first run +# Connection/auth via a Configuration; agent-behaviour knobs via config=AgentConfig(...). +ag.configure(configuration=Configuration(server_api_url="https://prod:8080/api")) # before first run result = ag.run(agent, "Hello!") ag.shutdown() # explicit cleanup; not required for simple scripts ``` @@ -61,13 +66,16 @@ ag.shutdown() # explicit cleanup; not required for simple scripts | `runtime.run(agent, prompt)` | yes | `AgentResult` | Simplest case — run and get the answer | | `runtime.start(agent, prompt)` | no | `AgentHandle` | Fire-and-forget; poll/control later | | `runtime.stream(agent, prompt)` | iterates | `AgentStream` | Watch events live; drive HITL | -| `runtime.deploy(*agents)` | yes | `list[DeploymentInfo]` | CI/CD: compile + register, no execution | -| `runtime.serve(*agents)` | yes (blocks) | — | Long-lived worker process; polls until interrupted | +| `runtime.deploy(*agents)` | yes | `list[DeploymentInfo]` | CI/CD: compile + register, no workers, no execution | +| `runtime.serve(*agents)` | yes (blocks) | — | Register agent(s) **and** serve workers; long-lived, polls until interrupted (`serve` = `deploy` + serve) | | `runtime.plan(agent)` | yes | `dict` | Compile to a workflow def without running anything | `run`/`start`/`stream` accept `media=`, `session_id=`, `idempotency_key=`, `credentials=`, and extra `**kwargs` as workflow input. `run`/`run_async` also accept `on_event=` to stream while running synchronously, `timeout=`, and `context=`. +`run`/`start` (and their async variants) also accept `run_settings=` — a `RunSettings` +(or dict) that overrides the agent's `model`/`temperature`/`max_tokens`/… for that one +call. See [API reference](api-reference.md#per-run-overrides--runsettings). `plan(agent)` returns `{"workflowDef": ..., "requiredWorkers": ...}` — useful to inspect the compiled Conductor workflow: @@ -88,9 +96,13 @@ runtime.deploy(agent) # agentspan deploy --path ./agents --agents greeter,support # Long-lived worker process: -runtime.serve(agent) # blocks, polling for tool tasks +runtime.serve(agent) # registers the agent (idempotent) + blocks, polling for tool tasks ``` +`serve` also registers the agent on the server, so the explicit `deploy` step above +is optional — it's still useful to register/upgrade the workflow from CI independently +of the worker rollout. + `resume(execution_id, agent)` re-attaches to a previously `start`ed execution and re-registers its tool workers (e.g. after a process restart): diff --git a/docs/agents/api-reference.md b/docs/agents/api-reference.md index e420c925..b1579b18 100644 --- a/docs/agents/api-reference.md +++ b/docs/agents/api-reference.md @@ -19,21 +19,25 @@ agents](framework-agents.md), and [Advanced](advanced.md). ## AgentRuntime -`AgentRuntime(*, server_url=None, api_key=None, api_secret=None, config=None)` +`AgentRuntime(configuration=None, *, settings=None)` — `configuration` is the +standard Conductor `Configuration` (host + auth; defaults to `Configuration()`, +which resolves `CONDUCTOR_SERVER_URL` → `AGENTSPAN_SERVER_URL` and +`CONDUCTOR_AUTH_*` from the environment); `settings` is an optional `AgentConfig` +with runtime behaviour knobs (its connection fields are ignored). Context manager (sync and async: `with` / `async with`). | Method | Signature | Purpose | |---|---|---| -| `run` | `(agent, prompt=None, *, version=None, media=None, session_id=None, idempotency_key=None, on_event=None, timeout=None, credentials=None, context=None, **kwargs) -> AgentResult` | Run synchronously | +| `run` | `(agent, prompt=None, *, version=None, media=None, session_id=None, idempotency_key=None, on_event=None, timeout=None, credentials=None, context=None, run_settings=None, **kwargs) -> AgentResult` | Start, wait for completion, return the result (also starts workers) | | `run_async` | same as `run` | Async run | -| `start` | `(agent, prompt=None, *, version=None, media=None, session_id=None, idempotency_key=None, context=None, **kwargs) -> AgentHandle` | Fire-and-forget | +| `start` | `(agent, prompt=None, *, version=None, media=None, session_id=None, idempotency_key=None, context=None, run_settings=None, **kwargs) -> AgentHandle` | Fire-and-forget; returns a handle (also starts workers) | | `start_async` | same as `start` | Async start | | `stream` | `(agent=None, prompt=None, *, version=None, handle=None, media=None, session_id=None, **kwargs) -> AgentStream` | Stream events | | `stream_async` | same as `stream` | `-> AsyncAgentStream` | -| `deploy` | `(*agents, packages=None, schedules=_UNSET) -> list[DeploymentInfo]` | Compile + register | +| `deploy` | `(*agents, packages=None, schedules=_UNSET) -> list[DeploymentInfo]` | Compile + register agent(s) on the server; does **not** start workers | | `deploy_async` | same | Async deploy | -| `serve` | `(*agents, packages=None, blocking=True) -> None` | Register + poll workers | +| `serve` | `(*agents, packages=None, blocking=True) -> None` | Register agent(s) on the server **and** serve/poll their workers (`serve` = `deploy` + serve) | | `plan` | `(agent) -> dict` | Compile to workflow def | | `resume` | `(execution_id, agent, *, timeout=None) -> AgentHandle` | Re-attach + re-register workers | | `resume_async` | same | Async resume | @@ -53,6 +57,28 @@ Async variants exist for status/respond/approve/reject/send/stop/shutdown `start`, `start_async`, `stream`, `stream_async`, `resume`, `resume_async`, `deploy`, `deploy_async`, `serve`, `plan`, `configure`, `shutdown`. +### Per-run overrides — `RunSettings` + +`run` / `start` (and their async variants and the module-level wrappers) accept +`run_settings=` to override an agent's LLM settings for a single invocation +without rebuilding the `Agent`: + +```python +from conductor.ai.agents import RunSettings + +runtime.run( + agent, + "Summarize this.", + run_settings=RunSettings(model="anthropic/claude-sonnet-4-6", temperature=0.0, max_tokens=512), +) +``` + +`RunSettings(model=None, temperature=None, max_tokens=None, reasoning_effort=None, +thinking_budget_tokens=None)` — only the fields you set override; unset fields keep +the agent's own values (so `temperature=0.0` is honored). A plain `dict` with the +same keys is also accepted. Overrides apply to the root agent's config; sub-agents +keep their own settings. + ## Agent `Agent(name, model="", instructions="", tools=None, agents=None, @@ -269,43 +295,46 @@ override. Pass instances via `Agent(callbacks=[...])`; they chain in list order. ## AgentClient -The control-plane client (formerly `AgentHttpClient`, alias kept). Reach it via -`runtime.client`, or construct standalone: -`AgentClient(server_url="", api_key="", auth_key="", auth_secret="", *, runtime=None)`. +The `/agent/*` control-plane client. `AgentClient` is an interface +(`conductor.client.agent_client`) implemented by `OrkesAgentClient` +(`conductor.client.orkes.orkes_agent_client`), following the same pattern as +`WorkflowClient`/`OrkesWorkflowClient` and built on the shared `ApiClient` +token machinery. Reach it via `runtime.client` or +`OrkesClients(configuration).get_agent_client()`. Every method has an `*_async` +counterpart. | Method | Signature | Purpose | |---|---|---| -| `run` / `run_async` | `(agent, prompt=None, *, media=None, session_id=None, idempotency_key=None, timeout=None, context=None, static_plan=None) -> AgentResult` | Compile + start + poll (no local workers) | -| `start` / `start_async` | same args | `-> AgentHandle` | -| `deploy` / `deploy_async` | `(*agents) -> list[DeploymentInfo]` | Compile + register | -| `schedule` | `(agent, schedules) -> DeploymentInfo` | Deploy + reconcile cron schedules | -| `get_status` | `(execution_id) -> dict` | | -| `respond` | `(execution_id, body) -> None` | | -| `stop` | `(execution_id) -> None` | | -| `signal` | `(execution_id, message) -> None` | | -| `stream_sse` | `(execution_id) -> AsyncIterator[dict]` | | -| `schedules` (property) | `-> SchedulerClient` | | -| `close` | `() -> None` (async) | | - -Lower-level endpoint methods (`start_agent`, `deploy_agent`, `compile_agent`) are also -available. The raw transport behind them is `conductor.client.ai.AgentApiClient` -(build one with `OrkesClients.get_agent_client()`); `AgentClient` composes it and -keeps the agent-level conveniences. +| `start_agent` | `(payload) -> dict` | POST /agent/start | +| `deploy_agent` | `(payload) -> dict` | POST /agent/deploy | +| `compile_agent` | `(payload) -> dict` | POST /agent/compile | +| `get_status` | `(execution_id) -> dict` | GET /agent/{id}/status | +| `get_execution` | `(execution_id) -> dict` | GET /agent/execution/{id} | +| `list_executions` | `(params=None) -> dict` | GET /agent/executions | +| `respond` | `(execution_id, body) -> None` | POST /agent/{id}/respond | +| `stop` | `(execution_id) -> None` | POST /agent/{id}/stop | +| `signal` | `(execution_id, message) -> None` | POST /agent/{id}/signal | +| `stream_sse` | `(execution_id, last_event_id=None) -> Iterator[dict]` | GET /agent/stream/{id} (SSE) | +| `schedules` (property) | `-> SchedulerClient` | Cron schedule lifecycle | +| `close` / `close_async` | `() -> None` | Release transport resources | ## Config and credentials `AgentConfig` (dataclass) fields: `server_url="http://localhost:8080/api"`, `api_key=None`, `auth_key=None`, `auth_secret=None`, `llm_retry_count=3`, `worker_poll_interval_ms=100`, `worker_thread_count=1`, `auto_start_workers=True`, -`auto_start_server=True`, `daemon_workers=True`, `auto_register_integrations=False`, +`daemon_workers=True`, `auto_register_integrations=False`, `streaming_enabled=True`, `secret_strict_mode=False`, `log_level="INFO"`. Classmethod `AgentConfig.from_env()` reads the `AGENTSPAN_*` variables (see [Getting started](getting-started.md#environment-variables)). Property `api_secret` aliases -`auth_secret`. +`auth_secret`. `AgentRuntime` uses it only for runtime *behaviour* knobs — server +connection always comes from the Conductor `Configuration`. `get_secret(name) -> str` — read a credential inside a `@tool(credentials=[...])` -function. `resolve_credentials(input_data, names) -> dict` — for external workers. -Errors: `CredentialNotFoundError`, `CredentialAuthError`, `CredentialRateLimitError`, +function. `resolve_credentials(task, names) -> dict` — for external workers; reads +the host-resolved values the server delivers on `Task.runtimeMetadata` (declared via +the tool/agent `credentials`, resolved at poll time). Errors: +`CredentialNotFoundError`, `CredentialAuthError`, `CredentialRateLimitError`, `CredentialServiceError`. `ClaudeCode(model_name="", permission_mode=PermissionMode.ACCEPT_EDITS)` with diff --git a/docs/agents/framework-agents.md b/docs/agents/framework-agents.md index 4c0724b4..53e06084 100644 --- a/docs/agents/framework-agents.md +++ b/docs/agents/framework-agents.md @@ -1,7 +1,7 @@ # Framework agents -Agentspan can run agents authored in other frameworks by bridging them onto its -durable runtime. You keep your framework's authoring API; Agentspan handles +Conductor Agent can run agents authored in other frameworks by bridging them onto its +durable runtime. You keep your framework's authoring API; Conductor Agent handles durability, retries, streaming, and observability. Supported bridges: **OpenAI Agents SDK**, **LangChain**, **LangGraph**, **Claude @@ -21,7 +21,7 @@ SDK's `Runner` with a native `Agent`. ### Drop-in `Runner` Change one import — `from conductor.ai import Runner` instead of `from agents import -Runner` — and run your existing OpenAI-Agents agent on Agentspan: +Runner` — and run your existing OpenAI-Agents agent on Conductor Agent: ```python from conductor.ai import Runner # the one line that changes @@ -86,7 +86,7 @@ with AgentRuntime() as runtime: result.print_result() ``` -Agentspan also provides a thin wrapper, `conductor.ai.agents.langchain.create_agent`, +Conductor Agent also provides a thin wrapper, `conductor.ai.agents.langchain.create_agent`, that captures the model, tools, and system prompt up front so they compile to native server-side model + tool tasks (rather than running the whole agent in one opaque worker). @@ -157,4 +157,4 @@ functions are not yet supported there. You can also bring `ClaudeCodeOptions` / a Claude Agent SDK agent directly; the bridge runs the full `query()` in one durable worker with instrumentation hooks that stream -tool-use and lifecycle events back to Agentspan. +tool-use and lifecycle events back to Conductor Agent. diff --git a/docs/agents/getting-started.md b/docs/agents/getting-started.md index 89227e6b..b9518efa 100644 --- a/docs/agents/getting-started.md +++ b/docs/agents/getting-started.md @@ -11,7 +11,7 @@ pip install 'conductor-python[agents]' Or, per framework, install just what you need — e.g. `conductor-python[langchain]`, `conductor-python[adk]`, `conductor-python[claude]`. -Point the SDK at a running Agentspan server (defaults to `http://localhost:8080/api`): +Point the SDK at a running Conductor Agent server (defaults to `http://localhost:8080/api`): ```bash export AGENTSPAN_SERVER_URL=http://localhost:8080/api @@ -60,7 +60,6 @@ the output if you prefer. | `AGENTSPAN_WORKER_POLL_INTERVAL` | `100` | Worker poll interval (ms) | | `AGENTSPAN_WORKER_THREADS` | `1` | Worker thread count | | `AGENTSPAN_AUTO_START_WORKERS` | `true` | Auto-start local tool workers | -| `AGENTSPAN_AUTO_START_SERVER` | `true` | Auto-start a local server if none is reachable | | `AGENTSPAN_DAEMON_WORKERS` | `true` | Run workers as daemon threads | | `AGENTSPAN_INTEGRATIONS_AUTO_REGISTER` | `false` | Auto-register provider integrations | | `AGENTSPAN_STREAMING_ENABLED` | `true` | Enable SSE streaming | diff --git a/docs/design/AGENT_SDK_PORTING_SPEC.md b/docs/design/AGENT_SDK_PORTING_SPEC.md new file mode 100644 index 00000000..46749792 --- /dev/null +++ b/docs/design/AGENT_SDK_PORTING_SPEC.md @@ -0,0 +1,708 @@ +# Agent SDK Porting Spec — Parity with Python SDK PR #421 + +**Version:** 1.1 +**Audience:** Coding agents (Claude, GPT, …) and SDK developers working in the +Conductor **Java / Go / C# / JavaScript-TypeScript** SDK repositories. +**Scope:** The delta required to bring an SDK's *agent layer* to parity with the +Python SDK after PR `conductor-oss/python-sdk#421` (branch +`feat/agent-client-worker-credentials`). +**Self-contained:** Every contract needed (endpoints, wire keys, semantics, +acceptance criteria) is stated in this document. You do not need access to the +Python repository. + +--- + +## How to use this spec + +1. Implement the numbered requirements **in the order given in §5** (they are + dependency-ordered). Keep your language's idioms — port the *contracts*, not + Python syntax. +2. Every requirement (R1–R12) ends with **acceptance criteria**. A requirement is + done only when all its criteria hold and are covered by tests. +3. If your SDK does not yet have an agent layer at all, R1–R3 still apply to the + core client (they are prerequisites), and the rest defines the agent layer you + will build. +4. MUST / MUST NOT / SHOULD are used in the RFC-2119 sense. + +### Design principles behind this delta + +- **Single token authority.** Every HTTP call in the agent layer — control plane + and worker-side — reuses the SDK's one authenticated API client (token + mint / cache / TTL-refresh / 401-retry). No second token cache anywhere. +- **Interface + implementation client pattern.** The agent control plane is a + first-class domain client, like Task/Workflow/Secret clients. +- **Configuration single-sourcing.** Server connection, auth, and log level live + on the SDK `Configuration` only. Agent-runtime settings hold behaviour knobs + only. +- **Fail-closed credentials.** Tool workers get secrets from the server on the + wire, per task — never from the worker's ambient environment. + +--- + +## 1. Summary of changes + +| # | Change | Kind | +|---|--------|------| +| R1 | `AgentClient` interface + `OrkesAgentClient` implementation on the standard API client; `OrkesClients.getAgentClient()` factory | **Add** | +| R2 | Public, TTL-aware `getAuthenticationHeaders()` on the API client (sync + async) | **Add** | +| R3 | `Configuration`: host env fallback + settable SDK-wide log level | **Change** | +| R4 | `AgentConfig` reduced to agent-runtime behaviour only; liveness fields added | **Change** | +| R5 | `AgentRuntime` constructor contract; all `/agent/*` traffic via `AgentClient`; server auto-start removed | **Change** | +| R6 | Worker credential delivery via `runtimeMetadata` (TaskDef declares names; Task delivers values); execution-token path retired | **Change** | +| R7 | Worker-side agent posts routed through a cached API client (single token authority); parallel token cache deleted | **Change** | +| R8 | `RunSettings` — per-run LLM overrides on `run`/`start` | **Add** | +| R9 | Verb contract: `serve` = `deploy` + serve; exact run/start/deploy/serve/plan semantics | **Change** | +| R10 | Worker-callable registration safety (spawn-safety invariant) | **Change** | +| R11 | Liveness monitor for stateful runs | **Add** | +| R12 | Deletions: bespoke agent HTTP clients, credentials fetcher, server auto-start, parallel token mint/cache, dead config fields | **Remove** | +| R13 | Swarm transfer contract: `message` hand-off note on transfer tools; `check_transfer` first-wins with `transfer_message` + `dropped_transfers` | **Change** | + +--- + +## 2. Requirements + +### R1 — `AgentClient` interface + Orkes implementation + +**Rationale:** The agent control plane (`/agent/*`) was previously reached through +bespoke HTTP transports with their own auth. It becomes a standard domain client: +an abstract interface plus a Conductor/Orkes implementation built on the SDK's +authenticated API client, wired into the client factory. + +**Interface.** Define `AgentClient` (interface/ABC) with these operations, each in +a sync and an async variant (per your language's conventions — e.g. Go may expose +context-based methods only; C# `*Async`; TS Promise-based only): + +| Method | HTTP call | Returns | +|---|---|---| +| `startAgent(payload)` | `POST /agent/start` | object (contains `executionId`, optional `requiredWorkers[]`) | +| `deployAgent(payload)` | `POST /agent/deploy` | object (contains `agentName`) | +| `compileAgent(payload)` | `POST /agent/compile` | object (compiled workflow def) | +| `getStatus(executionId)` | `GET /agent/{executionId}/status` | object | +| `getExecution(executionId)` | `GET /agent/execution/{executionId}` | object | +| `listExecutions(params?)` | `GET /agent/executions` (query params) | object | +| `respond(executionId, body)` | `POST /agent/{executionId}/respond` | void | +| `stop(executionId)` | `POST /agent/{executionId}/stop` | void | +| `signal(executionId, message)` | `POST /agent/{executionId}/signal` | void | +| `streamSse(executionId, lastEventId?)` | `GET /agent/stream/{executionId}` (SSE) | event iterator/stream | +| `close()` | — | release transports | + +**Implementation (`OrkesAgentClient`):** +- MUST extend/compose the SDK's shared base client and route all non-streaming + calls through the standard API client's generic call method (the same code path + the generated resource APIs use). This inherits authentication header injection, + token TTL refresh, and automatic one-shot retry on 401 **for free**. MUST NOT + add any token logic of its own. +- Paths are relative to `Configuration.host`, which already ends in `/api` — so + resource paths are `"/agent/..."` (leading slash, no `/api` prefix). +- **SSE** cannot go through the generic call method. Use a streaming HTTP + transport, but obtain the auth header from the API client's public accessor + (R2) per (re)connect. Send `Accept: text/event-stream` and, on reconnect, + `Last-Event-ID: `. Raise a dedicated `SSEUnavailableError` when the server + does not support streaming (caller falls back to polling). +- **Error mapping:** catch the transport's `ApiException`; map HTTP 404 → + `AgentNotFoundError`, everything else → `AgentAPIError` (both carrying status, + body, url). Do not leak raw transport exceptions. +- **Factory:** add `getAgentClient(): AgentClient` to the SDK's `OrkesClients` + factory, constructed from the same `Configuration` as every other client (hence + the same token cache). + +**Acceptance criteria** +- [ ] `OrkesClients.getAgentClient()` returns an `AgentClient` implementation. +- [ ] Every non-streaming call sends `X-Authorization: ` on a secured server + and retries exactly once after a 401 (token force-refresh) — inherited, not + re-implemented. +- [ ] SSE requests carry the same `X-Authorization` header sourced from the API + client (verify no second `POST /token` occurs for streaming). +- [ ] 404 from any `/agent/*` call surfaces as `AgentNotFoundError`; 5xx as + `AgentAPIError`. +- [ ] Interface-conformance test: the implementation satisfies the interface type. + +--- + +### R2 — Public TTL-aware auth-header accessor on the API client + +**Rationale:** Transports that bypass the generic call method (SSE, any custom +streaming) must reuse the API client's token rather than minting their own. + +**Contract:** Add a public method on the API client (sync and async variants): + +``` +getAuthenticationHeaders() -> { "X-Authorization": } | null +``` + +- Returns `null` for anonymous servers (no authentication settings). +- MUST be TTL-aware: if the cached token is older than the configured TTL, renew + it (same `POST /token` mint the client already uses) before returning. +- It is a thin public wrapper over the client's existing private token machinery — + do not duplicate mint/cache logic. + +**Acceptance criteria** +- [ ] Two calls within TTL → one mint. A call after TTL expiry → re-mint. +- [ ] Anonymous configuration → returns null/empty, no mint attempted. + +--- + +### R3 — `Configuration`: host fallback + SDK-wide log level + +**Contract:** +1. **Host resolution order** (when not passed explicitly): + `CONDUCTOR_SERVER_URL` → `AGENTSPAN_SERVER_URL` → default + `http://localhost:8080/api`. +2. **Log level**: `Configuration` accepts an optional `logLevel` (level name or + numeric level). Resolution order: explicit param → `CONDUCTOR_LOG_LEVEL` env → + `AGENTSPAN_LOG_LEVEL` env → derived from the `debug` flag (DEBUG/INFO). This is + the **only** log-level setting in the SDK; the agent runtime reads it from + `Configuration` (see R4 — `AgentConfig` has no log level). + +**Acceptance criteria** +- [ ] With only `AGENTSPAN_SERVER_URL` set, `Configuration()` resolves it; with + both set, `CONDUCTOR_SERVER_URL` wins. +- [ ] `Configuration(logLevel="WARNING")` and `CONDUCTOR_LOG_LEVEL=WARNING` both + yield WARNING; the agent runtime applies it to the agent loggers. + +--- + +### R4 — `AgentConfig` is agent-runtime behaviour ONLY + +**Rationale:** `AgentConfig` previously duplicated connection/auth/log settings, +creating two sources of truth. Connection, auth, and log level now come from +`Configuration` exclusively. + +**REMOVE from `AgentConfig`** (or never add): `serverUrl`, `apiKey`, `authKey`, +`authSecret` (and any `apiSecret` alias), `llmRetryCount` (dead), `secretStrictMode` +(dead — fail-closed is unconditional, see R6), `logLevel` (moved to Configuration). + +**KEEP / ADD** (defaults and env names): + +| Field | Type | Default | Env var | +|---|---|---|---| +| `workerPollIntervalMs` | int | 100 | `AGENTSPAN_WORKER_POLL_INTERVAL` | +| `workerThreadCount` | int | 1 | `AGENTSPAN_WORKER_THREADS` | +| `autoStartWorkers` | bool | true | `AGENTSPAN_AUTO_START_WORKERS` | +| `daemonWorkers` | bool | true | `AGENTSPAN_DAEMON_WORKERS` | +| `autoRegisterIntegrations` | bool | false | `AGENTSPAN_INTEGRATIONS_AUTO_REGISTER` | +| `streamingEnabled` | bool | true | `AGENTSPAN_STREAMING_ENABLED` | +| `livenessEnabled` | bool | true | `AGENTSPAN_LIVENESS_ENABLED` | +| `livenessStallSeconds` | float | 30.0 | `AGENTSPAN_LIVENESS_STALL_SECONDS` | +| `livenessCheckIntervalSeconds` | float | 10.0 | `AGENTSPAN_LIVENESS_CHECK_INTERVAL_SECONDS` | + +`AgentConfig.fromEnv()` reads the env vars above. It MUST NOT read server URL, +credentials, or log level. + +**Acceptance criteria** +- [ ] Constructing `AgentConfig` with a connection/auth/log field is a compile + error (typed languages) or raises (dynamic languages). +- [ ] `fromEnv()` honors each env var; empty string falls back to the default. +- [ ] Liveness defaults: enabled, 30.0s stall, 10.0s interval. + +--- + +### R5 — `AgentRuntime` construction and transport + +**Constructor contract:** + +``` +AgentRuntime(configuration?: Configuration, settings?: AgentConfig) +``` + +- `configuration` defaults to an env-resolved `Configuration()` and is the sole + source of host/auth/log level. `settings` defaults to `AgentConfig.fromEnv()`. +- The runtime builds its clients from ONE `OrkesClients(configuration)`: + `agentClient = clients.getAgentClient()` plus the workflow/task clients it + needs. All share one API client token cache. +- **Every** `/agent/*` operation (start, deploy, compile, status, execution list, + respond, stop, signal, SSE) goes through `agentClient`. The runtime MUST NOT + hold its own HTTP transport, base-URL builder, or token helper. +- Expose the transport as a read-only `client` property returning the + `AgentClient` (useful for tests and advanced callers). +- **REMOVE server auto-start:** no code that probes whether the agent server is + running or launches one. Absence of a server surfaces as a connection error. + +**Start flow** (shared by `run`/`start`, sync and async): + +``` +FUNCTION startInternal(agent, prompt, runSettings?, media?, sessionId?, + idempotencyKey?, timeout?, credentials?, context?, runId?): + configJson = serialize(agent) // agent -> agentConfig + IF runSettings != null: + applyOverrides(configJson, runSettings) // R8 + payload = { agentConfig: configJson, prompt: prompt, + sessionId: sessionId ?? "", media: media ?? [] } + // optional keys, only when set: + // context, idempotencyKey, timeoutSeconds, credentials, runId + data = agentClient.startAgent(payload) // server: compile+register+start + executionId = data["executionId"] + requiredWorkers = data["requiredWorkers"] // optional set of task names + prepareWorkers(agent, requiredWorkers) // start tool workers + RETURN executionId +``` + +**Acceptance criteria** +- [ ] `AgentRuntime(Configuration(serverApiUrl=X))` talks to X; an `AgentConfig` + passed as `settings` cannot alter connection or auth. +- [ ] Grep-level check: no `POST /token`, no raw URL string-building for + `/agent/*`, and no server-launch code anywhere in the runtime. +- [ ] `runtime.client` is the same `AgentClient` instance the runtime uses. + +--- + +### R6 — Worker credentials via `runtimeMetadata` (fail-closed) + +**Rationale:** Tool workers need secrets (API keys, tokens). The old design used a +per-execution token and a `POST /workers/secrets` fetch — a second auth path. +The new contract makes the *server* resolve secrets at poll time and deliver them +on the task, wire-only. + +**Model fields** (add to the SDK's HTTP models; JSON wire names are exact): + +| Model | Field | JSON wire name | Type | Semantics | +|---|---|---|---|---| +| `TaskDef` | runtimeMetadata | `runtimeMetadata` | `string[]` | Names of secrets this task type requires | +| `Task` | runtimeMetadata | `runtimeMetadata` | `map` | Name → resolved secret value; delivered on the wire at poll time; never persisted | + +**Registration (SDK side):** when a tool declares credentials +(`tool(credentials=["GH_TOKEN"])` or equivalent), the SDK MUST stamp those *names* +onto the tool's `TaskDef.runtimeMetadata` at worker registration. Because worker +registration typically uses overwrite semantics (`overwriteTaskDef=true`), the SDK +MUST re-stamp on every registration so a re-register does not wipe the +server-side value. + +**Dispatch (worker side):** before invoking the tool function: + +``` +FUNCTION resolveSecrets(task, declaredNames): + delivered = task.runtimeMetadata ?? {} + missing = [n FOR n IN declaredNames IF n NOT IN delivered] + IF missing NOT EMPTY: + THROW CredentialNotFoundError(missing) // task FAILS — no fallback + RETURN { n: delivered[n] FOR n IN declaredNames } +``` + +- Inject the resolved values into the tool's execution environment **for that call + only** (e.g. scoped env-var injection with restoration, or explicit parameter). +- **Fail closed:** if a declared credential was not delivered, the task fails with + a clear error. The worker MUST NOT fall back to reading the ambient process + environment. +- **RETIRE** the execution-token flow: remove any `executionToken` / + `__agentspan_ctx__` handling and any `POST /workers/secrets` client code. + +**Server dependency note:** this contract requires a server that persists +`TaskDef.runtimeMetadata` and delivers `Task.runtimeMetadata` +(conductor-oss PR #1255 / agentspan server > 0.4.2). Integration tests SHOULD +capability-probe (register a TaskDef with `runtimeMetadata`, read it back; skip +the suite if the server drops the field). + +**Acceptance criteria** +- [ ] Registering a tool with declared credentials produces a server TaskDef whose + `runtimeMetadata` equals the declared name list, and re-registration + preserves it. +- [ ] A task delivering `runtimeMetadata: {NAME: value}` results in the tool + seeing `value`; the injected value is gone after the call. +- [ ] With the secret absent server-side and `NAME` present in the worker's own + environment, the task FAILS (env value never used). +- [ ] No references to `workers/secrets` or execution tokens remain. + +--- + +### R7 — Single token authority for worker-side agent posts + +**Rationale:** Framework/tool workers running inside worker executors also call +the server (progress, events, sub-agent tracking). These calls previously used a +standalone token mint+cache. They must reuse the standard API client. + +**Constraint:** worker executors may be separate processes that cannot receive a +live client object; they receive plain strings `(serverUrl, authKey, authSecret)`. + +**Contract — an internal `agentHttp` helper:** + +``` +// Module/package-level cache. The API client constructor mints a token, +// so caching per credential is MANDATORY (never construct per call). +cache: Map<(serverUrl, authKey), ApiClient> // guarded by a lock + +FUNCTION agentApiClient(serverUrl, authKey, authSecret) -> ApiClient: + RETURN cache.getOrCreate((serverUrl, authKey), () -> + ApiClient(Configuration(serverApiUrl=serverUrl, + authenticationSettings=(authKey ? {authKey, authSecret} : null)))) + +FUNCTION agentPost(serverUrl, authKey, authSecret, path, body, + readResponse=false) -> object | null: + TRY: + client = agentApiClient(serverUrl, authKey, authSecret) + RETURN client.callApi(path, "POST", body, + responseType = readResponse ? "object" : none) + CATCH anything: + log_debug(...); RETURN null // graceful degradation — callers never crash +``` + +- `path` starts with `/` and omits `/api` (host already ends in `/api`). +- Fire-and-forget callers (event push, task-status completion) ignore the return. + Read-response callers interpret `null` as failure (e.g. "tracking workflow not + created", "injection failed → false"). +- Worker-side endpoints served by this helper: + `POST /agent/events/{executionId}` (event push), + `POST /agent/execution` (create tracking workflow → `{executionId}`), + `POST /agent/{executionId}/tasks` (inject display task), + `POST /agent/tasks/{executionId}/{refTaskName}/{status}` (complete injected task), + `POST /agent/execution/{executionId}/complete` (complete tracking workflow). + Task-progress updates use the native task client (`updateTask` with + `IN_PROGRESS`) built on the **same** cached client. +- **DELETE** the parallel token helper: any standalone + `POST {server}/token` mint with its own `(serverUrl, authKey) → (token, exp)` + cache. If a JWT-expiry decoder is used elsewhere, keep only the decoder. + +**Acceptance criteria** +- [ ] Two `agentPost` calls with the same `(serverUrl, authKey)` cause exactly + **one** `POST /token` mint; both requests carry `X-Authorization`. +- [ ] Anonymous (`authKey` empty): no mint, no auth header, request still sent. +- [ ] HTTP 500 and 404 both return `null` (no exception escapes). +- [ ] `readResponse=true` returns the parsed JSON object. +- [ ] Grep-level check: exactly one code path in the SDK performs `POST /token`. + +--- + +### R8 — `RunSettings`: per-run LLM overrides + +**Contract:** a small value type accepted by `run`/`start` (and async variants, +and any module-level convenience wrappers) as `runSettings`: + +| Field | Overrides `agentConfig` wire key | +|---|---| +| `model` | `model` | +| `temperature` | `temperature` | +| `maxTokens` | `maxTokens` | +| `reasoningEffort` | `reasoningEffort` | +| `thinkingBudgetTokens` | `thinkingConfig = {"enabled": true, "budgetTokens": }` | + +``` +FUNCTION applyOverrides(configJson, rs): + FOR (field, wireKey) IN mapping: + IF rs[field] != null: // null-check, NOT truthiness: + configJson[wireKey] = rs[field] // temperature=0.0, maxTokens=0 must apply +``` + +- Applied to the **serialized root** `agentConfig` after serialization, before + `startAgent` — SDK-side only, no new server field. Sub-agents keep their own + per-agent settings (no cascade). +- Only set fields override; unset fields keep the agent's values. +- Accept both the typed value and a plain map with the same keys (where idiomatic); + unknown keys are an error. +- Do NOT add `top_p`/`topP` — it does not exist in the agentConfig contract. + +**Acceptance criteria** +- [ ] Full override lands in the start payload (`model`, `temperature`, + `maxTokens`, `reasoningEffort`, `thinkingConfig`). +- [ ] No `runSettings` → payload equals the agent's own settings. +- [ ] Partial override changes only provided fields; `temperature=0.0` applies. +- [ ] `run` and `start` both forward `runSettings` to the shared start flow. + +--- + +### R9 — Verb contract (`serve` = `deploy` + serve) + +The runtime verbs MUST behave exactly as follows: + +| Method | Blocks | Returns | Starts workers | Registers agent on server | Executes | +|---|---|---|---|---|---| +| `run(agent, prompt, …)` | yes (poll to completion) | result object (`output`, `status`, ids, usage) | **yes** | via start (compile+register+start is one server call) | yes | +| `start(agent, prompt, …)` | no | handle object (`executionId`, `join()`, `stop()`, streaming) | **yes** | via start | yes | +| `deploy(*agents, …)` | yes | list of deployment info | **no** | **yes** (`POST /agent/deploy` per agent) | no | +| `serve(*agents, blocking=true)` | yes, until SIGINT/SIGTERM (`blocking=false` returns) | void | **yes** (registers tool workers + starts polling) | **yes** — deploys each agent before starting workers | no | +| `plan(agent)` | yes | compiled workflow def | no | no | no | + +- `run` = start + wait; both `run` and `start` also start the tool workers so the + scheduled tool tasks execute. +- Keep the rich return objects (result/handle) — do NOT return bare + ids/outputs; the id is `handle.executionId`, the output is `result.output`. +- **`serve` change:** in its per-agent loop, call the same internal deploy helper + `deployViaServer(agent, framework)` **before** registering/starting that agent's + workers. This covers native and framework agents, and is idempotent with + overwrite-style task-def registration (same ordering `run` already uses). +- `serve(blocking=false)` MUST return after registration + worker start (needed + for tests and embedding). + +**Acceptance criteria** +- [ ] `deploy` registers the workflow server-side and starts zero worker + processes. +- [ ] `serve(agent, blocking=false)` (a) triggers one deploy call per agent, + (b) deploy happens before worker start, (c) workers are polling on return. +- [ ] `start` returns immediately with a usable handle; `run` blocks and returns + the result; both started the required workers. + +--- + +### R10 — Worker-callable registration safety + +**Invariant (language-agnostic):** every callable registered as a worker/tool must +be **reconstructible in the executor context** that runs it. + +- If your worker executors are **spawned processes** (Python-style): callables + must be importable by qualified name or serializable by value — module-level + functions, or module-level classes instantiated with plain-data fields. Local + closures, lambdas capturing live objects, and functions defined inside other + functions MUST be rejected at registration time with an actionable error + ("define the callable at module level"). Entry-point scripts must guard + top-level orchestration with the language's main-module idiom so a re-import in + the child does not re-run it. +- If your executors are **threads in-process** (Java/Go/C#/TS typical): the + invariant reduces to "no capture of per-run mutable runtime state"; document it, + and keep factory boundaries clean regardless (next point). +- **Factory boundary:** framework/tool worker factories receive only plain data — + `(serverUrl, authKey, authSecret)` strings plus config — never a live runtime, + client, or connection object. Inside the executor they reconstruct clients via + R7's cached helper. +- Auto-generated workers (e.g. a CLI-command tool) must follow the same rule: + implement them as a module-level callable class holding plain config (allowed + commands, timeout, working dir), not a closure. + +**Acceptance criteria** +- [ ] Registering a closure/lambda as a tool fails fast with an actionable error + (process-spawn runtimes) or is impossible by API design (typed runtimes). +- [ ] A registration-time round-trip test: serialize/reconstruct each registered + callable the way the executor would, and invoke it. + +--- + +### R11 — Liveness monitor (stateful runs) + +**Rationale:** for stateful runs, tasks are routed to this process's workers via a +domain. If the worker dies, the server-side task sits with `pollCount=0` forever +and a blocking `join()`/`result()` would hang. + +**Contract:** when a run is stateful (domain-routed) and +`AgentConfig.livenessEnabled`: +- A monitor polls the workflow every `livenessCheckIntervalSeconds`. +- If a scheduled task has had **no polls** for `livenessStallSeconds`, the monitor + fires a stall: blocking waiters (`join`/`result`) raise a `WorkerStallError` + (message naming the stalled task) instead of blocking forever. +- The monitor stops when the run reaches a terminal state or the handle is closed. + +**Acceptance criteria** +- [ ] With a killed worker, `join(timeout=∞)` surfaces `WorkerStallError` within + `stall + interval` seconds. +- [ ] `livenessEnabled=false` disables the monitor entirely. + +--- + +### R12 — Deletions checklist + +Remove (and ensure nothing references them): + +- [ ] Bespoke agent HTTP transport classes (e.g. an `AgentApiClient` that minted + its own JWT) and any DX wrapper client around them. +- [ ] The credentials **fetcher** (execution-token + `POST /workers/secrets`). +- [ ] Agent **server auto-start / detection** logic and its config flag + (`autoStartServer` / `AGENTSPAN_AUTO_START_SERVER`). +- [ ] The **parallel token cache** (standalone `POST /token` mint helper) — keep + at most a JWT-`exp` decoder. +- [ ] Dead `AgentConfig` fields (R4 list) and any `toConductorConfiguration()` + bridge that mapped AgentConfig connection fields into `Configuration`. + +--- + +### R13 — Swarm transfer contract (hand-off note + first-wins) + +**Rationale:** in the swarm strategy, agents hand off via server-compiled +`{source}_transfer_to_{target}` tools, and the SDK supplies two system workers: +the per-tool **transfer worker** and the per-agent **`{name}_check_transfer`** +worker that inspects the LLM's `toolCalls` output. Two failure modes were +observed in production runs and are now part of the contract: + +1. A transfer used to carry **no payload** — only the routing (`transfer_to`) + survived, so the delegating agent's instructions were lost and a + tool-calls-only turn polluted the shared conversation as `[agent]: []`. +2. When the LLM emitted **multiple transfer calls in one turn**, all but the + first were **silently dropped** — the model's fan-out intent vanished with no + trace in any task output. + +The server compiler (conductor `agentspan-server` `MultiAgentCompiler`) now +generates transfer tools with a required `message` argument ("hand-off note"), +records honored hand-offs in the conversation as +`[source -> target]: `, drops `[]`/`{}` tool-call-only results from +the transcript, and round-trips the structured `context` (`_agent_state`) +through every swarm agent sub-workflow. The SDK's side of the contract is the +two workers: + +**Transfer worker (`{source}_transfer_to_{target}`)** +- MUST accept an optional string input `message` (the hand-off note the LLM + wrote for the receiving agent). +- MUST echo it back — output `{"message": ""}` when non-empty, `{}` + otherwise — so the note is visible in the task output / UI. The worker + remains otherwise a no-op: the hand-off itself is detected by + `check_transfer`, not by this task. +- The unreachable-target variant (targets excluded by `allowed_transitions`) + is unchanged: return an error string telling the model to pick another tool. + +**`{name}_check_transfer` worker** +- Input: `tool_calls` — the LLM task's `toolCalls` output; a list of objects + with at least `name` and `inputParameters`. +- Scan in emission order for names containing `_transfer_to_`. Selection is + **first-wins** (the swarm loop can only hand off to one agent per turn). +- Output contract: + + ```json + { + "is_transfer": true, + "transfer_to": "engineering_lead", + "transfer_message": "Design the REST API", + "dropped_transfers": [ // ONLY when >1 transfer call + {"transfer_to": "marketing_lead", "message": "Plan the launch"} + ] + } + ``` + + - `transfer_to` — text after the first `_transfer_to_` in the winning call's + name. + - `transfer_message` — the winning call's `inputParameters.message`, + stringified; `""` when absent (older tool schema) or null. + - `dropped_transfers` — every non-winning transfer call, in order, with the + same `{transfer_to, message}` shape. MUST be omitted when there is a + single transfer. Log a warning naming the honored target and the dropped + ones — the drop must never be silent. + - No transfer call at all → `{"is_transfer": false, "transfer_to": "", + "transfer_message": ""}`. + +**Acceptance criteria** +- [ ] Transfer worker echoes `message`; returns `{}` when called without one + (backward compatible with pre-`message` tool schemas). +- [ ] `check_transfer` returns `transfer_message` from the first transfer + call's `inputParameters.message`; `""` when the argument is missing. +- [ ] With two transfer calls in one turn: first wins, second appears in + `dropped_transfers`, and a warning is logged. +- [ ] With no transfer calls (or `tool_calls` null): `is_transfer=false`, + empty `transfer_to`/`transfer_message`, no `dropped_transfers` key. + +--- + +## 3. Wire contracts appendix + +### 3.1 Authentication + +``` +POST {server}/token +Body: {"keyId": "", "keySecret": ""} +Response: {"token": ""} +Header on all subsequent calls: X-Authorization: +``` +Anonymous servers: no mint; send no auth header. A 404 from `/token` means an +open (auth-disabled) server — treat as anonymous. + +### 3.2 Agent control-plane endpoints + +`{server}` ends in `/api` (e.g. `http://localhost:8080/api`). + +| Method + path | Body | Response | +|---|---|---| +| `POST /agent/start` | start payload (3.3) | `{"executionId": "...", "requiredWorkers": ["taskName", ...]?}` | +| `POST /agent/deploy` | `{"agentConfig": {...}}` or `{"framework": "...", "rawConfig": {...}}` | `{"agentName": "..."}` | +| `POST /agent/compile` | `{"agentConfig": {...}}` | compiled workflow def | +| `GET /agent/{executionId}/status` | — | status object | +| `GET /agent/execution/{executionId}` | — | execution object | +| `GET /agent/executions?…` | — | list object | +| `POST /agent/{executionId}/respond` | free-form JSON (HITL answer) | — | +| `POST /agent/{executionId}/stop` | — | — | +| `POST /agent/{executionId}/signal` | message | — | +| `GET /agent/stream/{executionId}` | SSE; headers `Accept: text/event-stream`, `X-Authorization`, `Last-Event-ID?` | `event:`/`data:`/`id:` frames | +| `POST /agent/events/{executionId}` | event object (worker-side push) | — | +| `POST /agent/execution` | `{"workflowName", "input", "parentWorkflowId"?, "parentWorkflowTaskId"?}` | `{"executionId": "..."}` | +| `POST /agent/{executionId}/tasks` | `{"taskDefName", "referenceTaskName", "type", "inputData", "subWorkflowParam"?}` | 2xx = injected | +| `POST /agent/tasks/{executionId}/{refTaskName}/{status}` | output data | — | +| `POST /agent/execution/{executionId}/complete` | output data | — | + +### 3.3 Start payload + +```json +{ + "agentConfig": { "...serialized agent (3.4)..." }, + "prompt": "user input", + "sessionId": "", + "media": [], + "context": { }, // optional + "idempotencyKey": "…", // optional + "timeoutSeconds": 120, // optional + "credentials": ["GH_TOKEN"], // optional — names to resolve for this run + "runId": "…", // optional — stateful worker-domain routing + "static_plan": { } // optional — PLAN_EXECUTE fixed plan +} +``` + +### 3.4 `agentConfig` LLM wire keys (subset relevant to RunSettings) + +Top level of the serialized agent config: + +```json +{ + "name": "…", + "model": "provider/model", + "temperature": 0.2, + "maxTokens": 512, + "reasoningEffort": "high", + "thinkingConfig": { "enabled": true, "budgetTokens": 2048 } +} +``` +Keys are absent when unset (serializers strip nulls); an override simply sets the +key. + +### 3.5 Credential models + +```json +// TaskDef (declaration — names only) +{ "name": "my_tool", "runtimeMetadata": ["GH_TOKEN", "DB_PASSWORD"], ... } + +// Task (delivery — wire-only, poll-time) +{ "taskId": "…", "taskDefName": "my_tool", + "runtimeMetadata": { "GH_TOKEN": "ghp_…", "DB_PASSWORD": "…" }, ... } +``` + +--- + +## 4. Acceptance test matrix + +Port these as integration-style tests against an in-process HTTP stub server +(preferred; count mints, capture headers/bodies) or mocks where that is the +repo's convention. + +| # | Test | Requirement | +|---|---|---| +| T1 | Token minted once per (serverUrl, authKey); cached across calls; TTL renewal | R1, R2, R7 | +| T2 | `X-Authorization` present on every control-plane and worker-side call | R1, R7 | +| T3 | 401 triggers one forced refresh + retry (then surfaces) | R1 | +| T4 | SSE request reuses the API-client token; no separate mint | R1, R2 | +| T5 | 404 → `AgentNotFoundError`; 5xx → `AgentAPIError` (control plane); worker-side posts degrade to null | R1, R7 | +| T6 | Host env fallback order; log level param/env order | R3 | +| T7 | `AgentConfig` rejects connection/auth fields; liveness env parsing | R4 | +| T8 | Runtime has no bespoke `/agent/*` transport; `runtime.client` identity | R5 | +| T9 | TaskDef.runtimeMetadata stamped with declared names; survives re-registration | R6 | +| T10 | Delivered secret injected per-call; missing secret fails task; ambient env NEVER read | R6 | +| T11 | `agentPost` mint-once, anonymous no-header, 500/404→null, readResponse→object | R7 | +| T12 | run_settings full/partial/zero-value override in start payload; none→agent's own | R8 | +| T13 | run/start forward runSettings; async variants too | R8 | +| T14 | deploy registers, starts nothing; serve deploys each agent before worker start; serve(blocking=false) returns | R9 | +| T15 | Closure/lambda tool registration fails fast (spawn runtimes) | R10 | +| T16 | Killed worker → `WorkerStallError` from join within stall+interval | R11 | +| T17 | Grep: no `workers/secrets`, no execution token, no parallel `/token` mint, no server auto-start | R6, R12 | +| T18 | Transfer worker echoes `message` / `{}` without one; `check_transfer` extracts `transfer_message`, first-wins with `dropped_transfers` + warning on multi-transfer, `is_transfer=false` on none | R13 | + +--- + +## 5. Implementation order & definition of done + +Dependency-ordered phases: + +1. **Phase A — core client:** R2 (auth-header accessor), R3 (Configuration). +2. **Phase B — control plane:** R1 (`AgentClient` + Orkes impl + factory), + R5 (runtime on the client; delete bespoke transports & auto-start). +3. **Phase C — credentials:** R6 (models, stamping, fail-closed dispatch; retire + fetcher). +4. **Phase D — worker-side token unification:** R7 (`agentHttp`; delete parallel + cache). +5. **Phase E — ergonomics:** R8 (`RunSettings`), R9 (verb contract, serve=deploy+serve). +6. **Phase F — hardening:** R10 (callable safety), R11 (liveness), R12 (deletion + sweep), R13 (swarm transfer contract). + +**Definition of done:** all R1–R13 acceptance criteria hold; the T1–T18 matrix is +implemented and green; the full SDK test suite passes; a repo-wide search finds no +references to the removed components (R12); and a live smoke test against an agent +server demonstrates: `deploy` (registered, no workers) → `serve(blocking=false)` +(registered + polling) → `start` with a `runSettings` override (override visible in +the execution's LLM task input) → `run` to completion. diff --git a/docs/design/WORKER_SDK_IMPLEMENTATION_GUIDE.md b/docs/design/WORKER_SDK_IMPLEMENTATION_GUIDE.md index 8c2db407..915fbc36 100644 --- a/docs/design/WORKER_SDK_IMPLEMENTATION_GUIDE.md +++ b/docs/design/WORKER_SDK_IMPLEMENTATION_GUIDE.md @@ -2364,7 +2364,223 @@ If you are an AI agent implementing a Conductor worker SDK: --- -## 25. Appendix: Algorithms +## 25. Agent SDK Layer (Building Agents on the Worker SDK) + +Sections 1–24 specify the **Worker SDK**. This section specifies an **Agent SDK** — +a higher-level layer built *on top of* the Worker SDK that lets developers author +LLM agents in plain code and run them as durable Conductor workflows. It reflects +the reference Python SDK's `conductor.ai.agents` package. + +### 25.1 Execution Model + +**Principle:** an agent is *authored* in the SDK but *compiled and executed on the +server*. The SDK does **not** run an agent loop locally — it serializes the agent to +an `agentConfig`, and the server compiles that into a Conductor **workflow** +(LLM-completion tasks, tool tasks, routing/switch tasks, and sub-workflows for +sub-agents). The agent's **tools are ordinary Conductor workers** — everything in +Sections 1–24 is reused unchanged to serve them. + +Two planes: + +| Plane | Responsibility | Transport | +|---|---|---| +| **Control plane** (`AgentClient`) | compile / deploy / start / status / stream / respond / stop / signal | HTTP to `/agent/*` endpoints via the standard authenticated API client | +| **Worker plane** (`WorkerManager` → `TaskHandler`/`TaskRunner`, Sections 4–5) | serve the agent's tool functions as Conductor workers | poll / execute / update (Section 5) | + +``` +Author Agent (code) + │ serialize → agentConfig + ▼ +AgentRuntime ──(control plane: AgentClient)──▶ Server: compile agentConfig → workflow, run it + │ │ schedules LLM tasks + tool tasks + └──(worker plane: WorkerManager/TaskHandler)──▶ polls & executes the agent's tool tasks +``` + +**Key consequence:** starting an agent is *compile + register + start* in one server +call; the SDK then starts the tool workers so the scheduled tool tasks get executed. + +### 25.2 AgentRuntime (facade) + +The single entry point. It composes: +- a standard **`Configuration`** (host + auth) — the *only* source of connection/auth; +- an **`AgentConfig`** (agent-runtime behaviour knobs; Section 25.4); +- an **`AgentClient`** (control plane; Section 25.3), built from the same `Configuration`; +- a **`WorkerManager`** wrapping the Worker SDK's `TaskHandler` (Sections 4–5). + +Constructor: `AgentRuntime(configuration=None, *, settings=None)` — `configuration` +defaults to an env-resolved `Configuration()`; `settings` is an optional `AgentConfig`. +Implements the context-manager protocol for lifecycle (sync and async). + +**Verb contract (implementations must match exactly):** + +| Method | Blocks | Returns | Starts workers? | Registers agent on server? | Runs execution? | +|---|---|---|---|---|---| +| `run(agent, prompt, …)` | yes | result object (output + status + ids) | yes | via start | yes | +| `start(agent, prompt, …)` | no | handle (carries `executionId`) | yes | via start | yes | +| `deploy(*agents, …)` | yes | list of deployment info | **no** | yes (compile + register) | no | +| `serve(*agents, …)` | yes (until signal) | — | yes (polls) | **yes** | no | +| `plan(agent)` | yes | compiled workflow def | no | no | no | +| `stream(agent, prompt, …)` | iterates | event stream | yes | via start | yes | + +- `run` = `start` + wait-for-completion; both `run` and `start` **also start the tool + workers** so scheduled tool tasks execute. +- `start` returns a *handle* (not a bare id); the id is `handle.execution_id`. `run` + returns a *result* (not bare output); the output is `result.output`. Return the rich + objects — they also carry status, token usage, streaming, and `join()`/`stop()`. +- `deploy` registers the workflow (+ task defs) on the server and starts **nothing** + (CI/CD operation). +- `serve` = **deploy + serve**: it registers each agent on the server *and* registers + + serves the local tool workers, then blocks polling until interrupted. Registration is + idempotent with the Worker SDK's `overwrite_task_def`. + +Provide async twins (`run_async`, `start_async`, `deploy_async`, `stream_async`, +`resume_async`) and HITL/lifecycle methods (`respond`/`approve`/`reject`, +`send_message`, `signal`, `get_status`, `pause`/`cancel`/`stop`, `shutdown`). + +**Start flow (shared by `run` and `start`):** + +``` +FUNCTION start_internal(agent, prompt, run_settings=null, …): + config_json = serialize(agent) // agent → agentConfig + IF run_settings: apply_overrides(config_json, run_settings) // Section 25.5 + payload = { agentConfig: config_json, prompt, sessionId, media, … } + data = agent_client.start_agent(payload) // server: compile + register + start + execution_id = data.executionId + required_workers = data.requiredWorkers // tool task defs this run needs + prepare_workers(agent, required_workers) // WorkerManager.start() — serve tools + RETURN execution_id +``` + +### 25.3 AgentClient (control-plane interface) + +An **interface** (mirroring the Worker SDK's client conventions) with a +Conductor/Orkes implementation. Methods — each with a sync and an async variant: +`start_agent`, `deploy_agent`, `compile_agent`, `get_status`, `get_execution`, +`list_executions`, `respond`, `stop`, `signal`, `stream_sse` (SSE generator), `close`. + +**Critical requirement — reuse ONE token authority.** Build the implementation on the +**standard authenticated API client** (the same client workers use, Sections 5/10), so +it inherits token mint / cache / TTL-refresh / 401-retry for free. Non-streaming calls +go through `call_api(resource_path="/agent/…", method, body, …)`. SSE streaming can't +use `call_api`, so it uses a streaming HTTP transport — but it **borrows the auth +header from the same client** (a public `get_authentication_headers()` accessor) rather +than minting separately. Do **not** implement a second token cache anywhere in the +agent layer (Section 25.6). + +Error mapping: the server `ApiException` maps to agent errors (`AgentAPIError`; HTTP +404 → `AgentNotFoundError`). + +### 25.4 AgentConfig (agent-runtime behaviour ONLY) + +`AgentConfig` holds **agent-runtime knobs only**. Connection, auth, and the SDK-wide +log level come from the standard `Configuration` — the single source. **Do not** +duplicate server URL, credentials, or log level in `AgentConfig`. + +| Field | Type | Default | Purpose | +|---|---|---|---| +| `worker_poll_interval_ms` | int | 100 | Tool-worker poll interval | +| `worker_thread_count` | int | 1 | Threads per tool worker | +| `auto_start_workers` | bool | true | Auto-start tool workers on run/start | +| `daemon_workers` | bool | true | Worker processes die with the parent (no orphans) | +| `auto_register_integrations` | bool | false | Auto-create LLM provider integrations on the server at run time | +| `streaming_enabled` | bool | true | `stream()` uses server-sent events | +| `liveness_enabled` | bool | true | Start a liveness monitor for stateful runs (Section 25.9) | +| `liveness_stall_seconds` | float | 30.0 | Idle window before a run is deemed stalled | +| `liveness_check_interval_seconds` | float | 10.0 | Liveness poll cadence | + +`AgentConfig.from_env()` reads the SDK's env prefix (`AGENTSPAN_*`). Connection/auth/log +resolution lives in `Configuration`: host from `CONDUCTOR_SERVER_URL` → +`AGENTSPAN_SERVER_URL`; auth from `CONDUCTOR_AUTH_*`; log level from +`CONDUCTOR_LOG_LEVEL` / `AGENTSPAN_LOG_LEVEL` (or the `debug` flag). + +### 25.5 RunSettings (per-run LLM overrides) + +`run`/`start` (and their async variants) accept an optional `run_settings` to override +the agent's LLM settings **for a single invocation** — without rebuilding the agent. + +| `RunSettings` field | Overrides `agentConfig` wire key | +|---|---| +| `model` | `model` | +| `temperature` | `temperature` | +| `max_tokens` | `maxTokens` | +| `reasoning_effort` | `reasoningEffort` | +| `thinking_budget_tokens` | `thinkingConfig = {enabled: true, budgetTokens: n}` | + +``` +FUNCTION apply_overrides(config_json, run_settings): + FOR field, wire_key IN mapping: + value = run_settings[field] + IF value IS NOT null: // NOT truthiness — honor temperature=0.0, max_tokens=0 + config_json[wire_key] = value +``` + +**Rules:** SDK-side only — mutate the serialized `agentConfig` *before* `start_agent` +(no server change). Only set fields override; unset fields keep the agent's own values. +Overrides apply to the **root** agent config; sub-agents keep their own per-agent +settings. Accept a typed `RunSettings` or a plain map of the same keys. + +### 25.6 Single Token Authority + +**Principle:** every HTTP call the agent layer makes — control plane (`/agent/*`) **and** +worker-side agent posts (event push, tool-task injection, tracking-workflow creation, +task progress) — must reuse the SDK's *one* authenticated client's token management. +Never run a parallel `POST /token` mint/cache beside the API client. + +Because tool workers run in **spawned child processes** that can't receive a live client +object, reconstruct a client inside the child from the plain connection strings +`(server_url, auth_key, auth_secret)` and **cache it per `(server_url, auth_key)`** — the +client constructor mints a token, so caching avoids a mint on every call. Post through +that client's `call_api` (which also provides 401-retry). + +### 25.7 Framework Adapters + +The agent layer can run agents authored in *foreign frameworks* (LangChain, LangGraph, +OpenAI Agents SDK, Claude Agent SDK) on the durable runtime. The foreign agent is +wrapped as a **passthrough worker**; its tools run as Conductor workers; lifecycle and +tool-use events are pushed to the server for observability. + +**Spawn-safety (critical).** Workers execute in spawned processes, so every worker/tool +callable must be **importable by qualified name or picklable by value** — a module-level +function, or a module-level callable *class instance* holding only plain data. **Never** +register a `` closure or a lambda. Entry scripts must guard top-level execution +with the language's "main module" guard (e.g. `if __name__ == "__main__":`) so a +re-imported spawn child does not re-run the orchestration. Framework worker factories +receive plain strings (server URL + credentials), never live client objects. + +### 25.8 Agent Credentials (runtimeMetadata contract) + +Tool workers receive secrets via a **wire-only delivery** contract, never from the +ambient environment: +1. A tool declares required credential **names**; the SDK stamps them on the tool's + `TaskDef.runtimeMetadata`. +2. At poll time the server resolves those names against its credential store and + delivers the **values** on the wire-only `Task.runtimeMetadata` (not persisted). +3. The worker injects the values into the environment **for that call only**; if a + declared credential was not delivered, the worker **fails** rather than reading the + ambient environment. + +### 25.9 Liveness Monitoring (stateful runs) + +For a stateful run the client cannot distinguish a slow agent from a dead worker. A +**liveness monitor** polls the workflow every `liveness_check_interval_seconds`; if a +task has had no polls (worker died/disconnected) for `liveness_stall_seconds`, it fires +a stall so `result()`/`join()` raise a worker-stall error instead of blocking forever. +Toggle via `AgentConfig.liveness_enabled`. + +### 25.10 Agent-Layer Implementation Checklist + +- [ ] `AgentClient` interface + implementation on the standard authenticated API client (sync + async); SSE reuses the client's auth header. +- [ ] `AgentRuntime` facade: `run`/`start`/`deploy`/`serve`/`plan`/`stream` (+ async) with the exact verb contract (25.2). +- [ ] `AgentConfig` = behaviour-only; connection/auth/log level come from `Configuration`. +- [ ] `RunSettings` per-run overrides applied to the serialized `agentConfig` (25.5). +- [ ] Single token authority across control plane + worker-side posts (25.6). +- [ ] Tool workers reuse the Worker SDK (Sections 4–5); credential delivery via `runtimeMetadata` (25.8). +- [ ] Framework adapters as spawn-safe passthrough workers (25.7). +- [ ] Liveness monitor for stateful runs (25.9). + +--- + +## 26. Appendix: Algorithms ### A. Cleanup Completed Tasks @@ -2429,7 +2645,7 @@ FUNCTION merge_context_modifications( --- -## 26. Glossary +## 27. Glossary **AsyncTaskRunner:** Worker execution engine using event loop/coroutines for async workers **Batch Polling:** Requesting multiple tasks in single API call @@ -2453,7 +2669,12 @@ FUNCTION merge_context_modifications( --- -## 27. Version History +## 28. Version History + +**v1.1:** +- Added Section 25: **Agent SDK Layer** (AgentRuntime, AgentClient, AgentConfig, + RunSettings, framework adapters, single token authority, runtimeMetadata + credentials, liveness monitoring) — the agent SDK built on top of the Worker SDK. **v1.0 (2025-11-30):** - Initial release @@ -2463,7 +2684,7 @@ FUNCTION merge_context_modifications( --- -## 28. Contributing +## 29. Contributing This is a living document. If you implement a worker SDK in another language: diff --git a/e2e/conftest.py b/e2e/conftest.py index 45e16eea..85324fa6 100644 --- a/e2e/conftest.py +++ b/e2e/conftest.py @@ -15,11 +15,6 @@ MODEL = os.environ.get("AGENTSPAN_LLM_MODEL", "openai/gpt-4o-mini") -# ── Prevent runtime from auto-starting a second server ────────────────── - -os.environ["AGENTSPAN_AUTO_START_SERVER"] = "false" - - # ── Markers ───────────────────────────────────────────────────────────── @@ -156,3 +151,63 @@ def get_task_by_name(execution_id: str, task_ref_prefix: str) -> list: for t in wf.get("tasks", []) if task_ref_prefix in t.get("referenceTaskName", "") ] + + +# ── Server capability: TaskDef.runtimeMetadata (conductor-oss PR #1255) ── +# +# Worker credential delivery relies on the server persisting a worker's declared +# TaskDef.runtimeMetadata and delivering the resolved values on Task.runtimeMetadata +# at poll time. Released servers (through v0.4.2) do NOT implement this yet — they +# drop the field — so the credential-delivery e2e tests would fail there for a +# reason unrelated to the SDK. Probe the running server once and skip those tests +# when unsupported. +# +# TODO: once agentspan cuts a release that implements runtimeMetadata, bump +# AGENTSPAN_VERSION in .github/workflows/agent-e2e.yml — this guard then lets the +# credential tests run automatically (no test change needed). + +_RUNTIME_METADATA_SUPPORT = None + + +def _probe_runtime_metadata_support() -> bool: + import uuid + + name = f"_rtmd_cap_probe_{uuid.uuid4().hex[:8]}" + try: + r = requests.post( + f"{BASE_URL}/api/metadata/taskdefs", + json=[{"name": name, "runtimeMetadata": ["PROBE"], + "retryCount": 0, "timeoutSeconds": 0}], + timeout=8, + ) + if r.status_code not in (200, 204): + return False + g = requests.get(f"{BASE_URL}/api/metadata/taskdefs/{name}", timeout=8) + return g.status_code == 200 and g.json().get("runtimeMetadata") == ["PROBE"] + except Exception: + return False + finally: + try: + requests.delete(f"{BASE_URL}/api/metadata/taskdefs/{name}", timeout=5) + except Exception: + pass + + +def _server_supports_runtime_metadata() -> bool: + global _RUNTIME_METADATA_SUPPORT + if _RUNTIME_METADATA_SUPPORT is None: + _RUNTIME_METADATA_SUPPORT = _probe_runtime_metadata_support() + return _RUNTIME_METADATA_SUPPORT + + +@pytest.fixture +def requires_runtime_metadata(): + """Skip a test unless the server implements the TaskDef.runtimeMetadata + credential-delivery contract (conductor-oss PR #1255).""" + if not _server_supports_runtime_metadata(): + pytest.skip( + "server does not persist/deliver TaskDef.runtimeMetadata " + "(conductor-oss PR #1255) — worker credential injection requires it. " + "TODO: bump AGENTSPAN_VERSION in .github/workflows/agent-e2e.yml once a " + "release ships runtimeMetadata support." + ) diff --git a/e2e/test_suite23_from_instance_and_event_hitl.py b/e2e/test_suite23_from_instance_and_event_hitl.py index 0bc98d31..e53529ac 100644 --- a/e2e/test_suite23_from_instance_and_event_hitl.py +++ b/e2e/test_suite23_from_instance_and_event_hitl.py @@ -200,7 +200,7 @@ def test_event_without_execution_id_raises(self): def test_respond_url_matches_server_wire_format(self, runtime): """The respond URL is /api/agent/{executionId}/respond (Java parity).""" - url = runtime._agent_api_url(f"/{self.SUB_EXEC}/respond") + url = runtime.client._agent_url(f"/{self.SUB_EXEC}/respond") assert url.endswith(f"/agent/{self.SUB_EXEC}/respond"), ( f"respond must POST to /api/agent/{{id}}/respond; got {url!r}." ) diff --git a/e2e/test_suite24_agent_client.py b/e2e/test_suite24_agent_client.py index 384900b2..cc996329 100644 --- a/e2e/test_suite24_agent_client.py +++ b/e2e/test_suite24_agent_client.py @@ -1,18 +1,21 @@ -"""Suite 24: AgentClient — control-plane run + schedule surface. +"""Suite 24: AgentClient (transport) + runtime run/start + schedule surface. -Verifies the control-plane :class:`AgentClient` (formerly ``AgentHttpClient``) -exposed via ``runtime.client``: +``runtime.client`` is the transport :class:`AgentClient` +(``conductor.client.orkes.orkes_agent_client.OrkesAgentClient``) — the +``/agent/*`` control-plane endpoints built on the shared ``ApiClient``. The +run/start DX lives on the runtime; the schedule lifecycle is reachable from both +``runtime.schedules_client()`` and ``runtime.client.schedules``. -- ``run`` on an LLM-only agent (no local tools) reaches status COMPLETED. - Control-plane only: no local tool workers are registered/polled. -- ``schedule(agent, [Schedule(...)])`` deploys + reconciles; the schedule then - shows up in ``list_for_agent``. A counterfactual ``reconcile([])`` purges it. -- The runtime's schedule surface (``runtime.schedules_client()``) and the - client's (``runtime.client.schedules``) are the *same* instance. +Verifies: +- ``runtime.run`` on an LLM-only agent (no local tools) reaches COMPLETED. +- ``runtime.start`` returns a handle that joins to COMPLETED. +- ``schedules.reconcile(agent, [Schedule(...)])`` upserts and lists; a + counterfactual ``reconcile([])`` purges it. +- ``runtime.client`` is the runtime's own transport client, and both schedule + accessors return working ``SchedulerClient``s. -No LLM is used for validation — assertions are on workflow status / schedule -structure only (per CLAUDE.md rule 1). The scheduled "agent" target is a bare -no-op Conductor workflow so no LLM is invoked for the schedule tests. +The scheduled "agent" target is a bare no-op Conductor workflow so no LLM is +invoked for the schedule tests. Targets the live Agentspan server (``AGENTSPAN_SERVER_URL``). The schedule tests are skipped automatically if the server's Conductor lacks the scheduler @@ -63,24 +66,22 @@ def test_run_llm_only_agent_completes(self, runtime, model): instructions="You are a calculator. Reply with only the number.", ) - result = runtime.client.run(agent, "What is 2 + 2? Reply with only the number.") + result = runtime.run(agent, "What is 2 + 2? Reply with only the number.") assert result.status == Status.COMPLETED, ( f"expected COMPLETED, got {result.status} (error={result.error})" ) assert result.execution_id - # No local tool workers were started for this control-plane run. - assert runtime._workers_started is False def test_start_returns_handle_then_joins(self, runtime, model): - """AgentClient.start returns a handle that joins to a COMPLETED result.""" + """runtime.start returns a handle that joins to a COMPLETED result.""" agent = Agent( name=f"e2e_client_start_{uuid.uuid4().hex[:8]}", model=model, instructions="Reply with the single word: ok", ) - handle = runtime.client.start(agent, "Say ok") + handle = runtime.start(agent, "Say ok") assert handle.execution_id result = handle.join(timeout=120) assert result.status == Status.COMPLETED @@ -145,16 +146,22 @@ def test_schedule_then_list_then_purge(self, runtime, noop_agent_name): assert not schedules.get_all_schedules(workflow_name=noop_agent_name) -# ── structural consistency: runtime + client share one schedule surface ── +# ── structural consistency: transport client + schedule accessors ──────── class TestScheduleSurfaceConsistency: - def test_runtime_and_client_share_schedule_client(self, runtime): - """runtime.schedules_client() and runtime.client.schedules are identical.""" - from_runtime = runtime.schedules_client() - from_client = runtime.client.schedules - assert from_runtime is from_client - - def test_client_is_bound_to_runtime(self, runtime): - """runtime.client is the runtime's own control-plane client (not a copy).""" - assert runtime.client is runtime._http + def test_both_schedule_accessors_are_scheduler_clients(self, runtime): + """Both runtime.schedules_client() and runtime.client.schedules return a + working SchedulerClient (the transport client builds its own from the same + Configuration; they are no longer required to be the same instance).""" + from conductor.client.scheduler_client import SchedulerClient + + assert isinstance(runtime.schedules_client(), SchedulerClient) + assert isinstance(runtime.client.schedules, SchedulerClient) + + def test_client_is_the_runtime_agent_client(self, runtime): + """runtime.client is the runtime's own transport AgentClient (not a copy).""" + from conductor.client.agent_client import AgentClient + + assert runtime.client is runtime._agent_client + assert isinstance(runtime.client, AgentClient) diff --git a/e2e/test_suite26_worker_credentials.py b/e2e/test_suite26_worker_credentials.py new file mode 100644 index 00000000..c10272fa --- /dev/null +++ b/e2e/test_suite26_worker_credentials.py @@ -0,0 +1,231 @@ +"""Suite 26: worker credentials via runtimeMetadata + CLI spawn-safety. + +Covers the SDK's credential-delivery contract and the CLI-tool worker fix: + +1. A ``@tool(credentials=[NAME])`` worker receives the secret value the host + resolved at poll time and delivered on the wire-only ``Task.runtimeMetadata`` + (conductor-oss PR #1255) — injected into ``os.environ`` for the call. +2. The registered ``TaskDef.runtimeMetadata`` declares the secret name (so the + SDK's overwrite-registration does not wipe the server-compiled value). +3. Credential isolation: with the secret absent from the server store, the + worker task fails (the value is NOT read from the ambient environment). +4. An agent using ``cli_allowed_commands`` registers its auto-generated + ``run_command`` worker without a spawn-safety error and runs to completion. + +Secrets are stored via the server's REST ``/api/secrets`` endpoint directly (no +CLI dependency). Tool outputs are asserted from the workflow task's ``outputData`` +(not LLM phrasing) for determinism. + +Targets the live Agentspan server (``AGENTSPAN_SERVER_URL``). +""" + +from __future__ import annotations + +import os +import uuid + +import pytest +import requests + +from conductor.ai.agents import Agent, tool + +pytestmark = [pytest.mark.e2e, pytest.mark.xdist_group("credentials")] + +TIMEOUT = 120 +CRED_NAME = "E2E_WORKER_SECRET" +CRED_VALUE = f"s3cr3t-{uuid.uuid4().hex[:12]}" + +_API = os.environ.get("AGENTSPAN_SERVER_URL", "http://localhost:8080/api").rstrip("/") +_BASE = _API.replace("/api", "") + + +@pytest.fixture() +def runtime(): + """Function-scoped runtime (overrides the module-scoped conftest fixture). + + Each test gets a fresh runtime so it re-registers the worker TaskDef (with + overwrite) — making TaskDef.runtimeMetadata reflect *this* test's code path + rather than a value left by a prior test in the module (the module-scoped + runtime caches registered tool names and would otherwise skip re-registration, + letting a stale/server-recompiled value mask a regression).""" + from conductor.ai.agents import AgentRuntime + + with AgentRuntime() as rt: + yield rt + + +# ── Tool: reports whether the declared secret was injected ─────────────── + + +@tool(credentials=[CRED_NAME]) +def report_secret(note: str = "") -> dict: + """Report whether the E2E worker secret was injected into the environment. + + Call this exactly once. + """ + val = os.environ.get(CRED_NAME, "") + return {"injected": bool(val), "value_prefix": val[:8], "note": note} + + +def _make_agent(model: str, name_suffix: str) -> Agent: + return Agent( + name=f"e2e_worker_creds_{name_suffix}", + model=model, + instructions=( + "You have one tool: report_secret. Call it exactly once with " + "note='check', then report its output verbatim." + ), + tools=[report_secret], + ) + + +# ── REST helpers (no CLI) ──────────────────────────────────────────────── + + +def _put_secret(name: str, value: str) -> None: + r = requests.put( + f"{_API}/secrets/{name}", + data=value, + headers={"Content-Type": "text/plain"}, + timeout=10, + ) + r.raise_for_status() + + +def _delete_secret(name: str) -> None: + try: + requests.delete(f"{_API}/secrets/{name}", timeout=10) + except Exception: + pass + + +def _get_workflow(execution_id: str) -> dict: + r = requests.get(f"{_BASE}/api/workflow/{execution_id}", timeout=10) + r.raise_for_status() + return r.json() + + +def _find_tool_task(execution_id: str, tool_name: str) -> dict | None: + wf = _get_workflow(execution_id) + for t in wf.get("tasks", []): + ref = t.get("referenceTaskName", "") or "" + tdn = t.get("taskDefName", "") or "" + if tool_name in ref or tool_name in tdn: + return t + return None + + +def _get_taskdef(name: str) -> dict | None: + r = requests.get(f"{_API}/metadata/taskdefs/{name}", timeout=10) + if r.status_code != 200: + return None + return r.json() + + +# ── Tests ──────────────────────────────────────────────────────────────── + + +@pytest.mark.timeout(300) +@pytest.mark.usefixtures("requires_runtime_metadata") +class TestWorkerCredentials: + def test_credential_injected_via_runtime_metadata(self, runtime, model): + """The tool receives the server-resolved secret (Task.runtimeMetadata).""" + _put_secret(CRED_NAME, CRED_VALUE) + try: + result = runtime.run( + _make_agent(model, "inject"), + "Call report_secret with note='check'.", + timeout=TIMEOUT, + ) + assert result.execution_id, f"no execution_id (status={result.status})" + + task = _find_tool_task(result.execution_id, "report_secret") + assert task is not None, "report_secret task not found in workflow" + out = task.get("outputData", {}) or {} + assert out.get("injected") is True, ( + f"secret was NOT injected into the worker environment: {out} " + f"(task status={task.get('status')}, " + f"reason={task.get('reasonForIncompletion')})" + ) + assert out.get("value_prefix") == CRED_VALUE[:8], ( + f"injected value mismatch: {out.get('value_prefix')!r} " + f"!= {CRED_VALUE[:8]!r}" + ) + finally: + _delete_secret(CRED_NAME) + + def test_taskdef_declares_runtime_metadata(self, runtime, model): + """The registered TaskDef carries runtimeMetadata=[CRED_NAME] (not wiped + by the SDK's overwrite-registration).""" + _put_secret(CRED_NAME, CRED_VALUE) + try: + runtime.run( + _make_agent(model, "taskdef"), + "Call report_secret with note='x'.", + timeout=TIMEOUT, + ) + td = _get_taskdef("report_secret") + assert td is not None, "report_secret TaskDef not registered on server" + assert td.get("runtimeMetadata") == [CRED_NAME], ( + f"expected TaskDef.runtimeMetadata == [{CRED_NAME!r}], " + f"got {td.get('runtimeMetadata')!r}" + ) + finally: + _delete_secret(CRED_NAME) + + def test_missing_credential_is_not_read_from_env(self, runtime, model): + """Isolation: with the secret absent from the server store, the worker + does NOT resolve it from the ambient environment — the tool task fails + rather than injecting an env value.""" + _delete_secret(CRED_NAME) # ensure absent on the server + os.environ[CRED_NAME] = "LEAKED_FROM_ENV" # must NOT be picked up + try: + result = runtime.run( + _make_agent(model, "isolation"), + "Call report_secret with note='check'.", + timeout=TIMEOUT, + ) + assert result.execution_id + task = _find_tool_task(result.execution_id, "report_secret") + if task is not None: + # The worker must fail credential resolution, never succeed by + # reading the ambient env var. + out = task.get("outputData", {}) or {} + assert out.get("value_prefix") != "LEAKED_F", ( + "SECURITY: worker read the credential from the ambient " + "environment instead of the server store" + ) + assert task.get("status") != "COMPLETED" or not out.get("injected"), ( + f"worker should not have injected a value: {out}" + ) + finally: + os.environ.pop(CRED_NAME, None) + _delete_secret(CRED_NAME) + + +@pytest.mark.timeout(300) +class TestCliSpawnSafety: + def test_cli_allowed_commands_agent_runs(self, runtime, model): + """An agent using cli_allowed_commands registers its auto-generated + run_command worker without a SpawnSafetyError and reaches a terminal + status. (Before the fix, registration raised because run_command was a + closure.)""" + agent = Agent( + name=f"e2e_cli_spawn_{uuid.uuid4().hex[:8]}", + model=model, + instructions=( + "You have a run_command tool. Use it to run 'ls' on the /tmp " + "directory, then report the output." + ), + cli_commands=True, + cli_allowed_commands=["ls"], + ) + # If run_command were not spawn-safe, runtime.run would raise during + # worker registration before returning any result. + result = runtime.run( + agent, "Run 'ls' on the /tmp directory using run_command.", timeout=TIMEOUT + ) + assert result.execution_id, f"no execution_id (status={result.status})" + assert result.status in ("COMPLETED", "FAILED", "TERMINATED"), ( + f"expected a terminal status, got {result.status}" + ) diff --git a/e2e/test_suite2_tool_calling.py b/e2e/test_suite2_tool_calling.py index 5124a800..68d66c31 100644 --- a/e2e/test_suite2_tool_calling.py +++ b/e2e/test_suite2_tool_calling.py @@ -295,6 +295,7 @@ def _get_output_text(result) -> str: class TestSuite2ToolCalling: """Credential lifecycle: missing -> env ignored -> add -> update.""" + @pytest.mark.usefixtures("requires_runtime_metadata") def test_credential_lifecycle(self, runtime, cli_credentials, model): """Full credential lifecycle test — sequential steps with cleanup.""" try: diff --git a/e2e/test_suite3_cli_tools.py b/e2e/test_suite3_cli_tools.py index 11745cea..76291848 100644 --- a/e2e/test_suite3_cli_tools.py +++ b/e2e/test_suite3_cli_tools.py @@ -240,6 +240,7 @@ def _assert_run_completed(result, step_name: str): class TestSuite3CliTools: """CLI tools: credential lifecycle + command whitelist.""" + @pytest.mark.usefixtures("requires_runtime_metadata") def test_cli_credential_lifecycle(self, runtime, cli_credentials, model): """Full CLI credential lifecycle — sequential steps with cleanup.""" real_token = os.environ.get("GITHUB_TOKEN") diff --git a/examples/agents/16_credentials_isolated_tool.py b/examples/agents/16_credentials_isolated_tool.py index 8bef3aa7..0b6721ec 100644 --- a/examples/agents/16_credentials_isolated_tool.py +++ b/examples/agents/16_credentials_isolated_tool.py @@ -4,7 +4,7 @@ """Credentials — per-user secrets injected into isolated tool subprocesses. Demonstrates: - - @tool with credentials=["GITHUB_TOKEN"] declares the tool's secret needs + - @tool with credentials=["GH_TOKEN"] declares the tool's secret needs - Credentials injected into a fresh subprocess — parent env never touched - Tool reads credential from os.environ inside the subprocess - Fallback to os.environ when no server credential is set (non-strict mode) @@ -18,12 +18,12 @@ Setup (one-time, via CLI): agentspan login # authenticate - agentspan credentials set GITHUB_TOKEN # enter token when prompted + agentspan credentials set GH_TOKEN # enter token when prompted Requirements: - Agentspan server running at AGENTSPAN_SERVER_URL - AGENTSPAN_LLM_MODEL set (or defaults to openai/gpt-5.4) - - GITHUB_TOKEN stored via `agentspan credentials set` OR set in os.environ + - GH_TOKEN stored via `agentspan credentials set` OR set in os.environ """ import os @@ -34,13 +34,13 @@ from conductor.ai.agents import Agent, AgentRuntime, tool -@tool(credentials=["GITHUB_TOKEN"]) +@tool(credentials=["GH_TOKEN"]) def list_github_repos(username: str) -> dict: """List public repositories for a GitHub user. - The GITHUB_TOKEN env var is injected into this subprocess automatically. + The GH_TOKEN env var is injected into this subprocess automatically. """ - token = os.environ.get("GITHUB_TOKEN", "") + token = os.environ.get("GH_TOKEN", "") headers = ["Accept: application/vnd.github+json"] if token: headers.append(f"Authorization: Bearer {token}") @@ -72,15 +72,15 @@ def list_github_repos(username: str) -> dict: } -@tool(credentials=["GITHUB_TOKEN"]) +@tool(credentials=["GH_TOKEN"]) def create_github_issue(repo: str, title: str, body: str) -> dict: - """Create a GitHub issue. Requires GITHUB_TOKEN with write access. + """Create a GitHub issue. Requires GH_TOKEN with write access. repo format: "owner/repo-name" """ - token = os.environ.get("GITHUB_TOKEN") + token = os.environ.get("GH_TOKEN") if not token: - return {"error": "GITHUB_TOKEN not available — cannot create issues without auth"} + return {"error": "GH_TOKEN not available — cannot create issues without auth"} import json @@ -117,7 +117,7 @@ def create_github_issue(repo: str, title: str, body: str) -> dict: model=settings.llm_model, tools=[list_github_repos, create_github_issue], # Declare credentials at the agent level — SDK auto-fetches for all tools - credentials=["GITHUB_TOKEN"], + credentials=["GH_TOKEN"], instructions=( "You are a GitHub assistant. You can list repos and create issues. " "Always confirm with the user before creating issues." diff --git a/examples/agents/16c_credentials_cli_tools.py b/examples/agents/16c_credentials_cli_tools.py index 60648692..a2ace721 100644 --- a/examples/agents/16c_credentials_cli_tools.py +++ b/examples/agents/16c_credentials_cli_tools.py @@ -11,7 +11,7 @@ Setup (one-time, via CLI): agentspan login - agentspan credentials set GITHUB_TOKEN + agentspan credentials set GH_TOKEN agentspan credentials set AWS_ACCESS_KEY_ID agentspan credentials set AWS_SECRET_ACCESS_KEY Requirements: @@ -20,15 +20,16 @@ - gh and aws CLIs installed """ -import os import subprocess from conductor.ai.agents import Agent, AgentRuntime, tool from settings import settings -# gh tool — requires GITHUB_TOKEN -@tool(credentials=["GITHUB_TOKEN"]) +# gh tool — requires GH_TOKEN (the env var the gh CLI reads natively). The +# runtime injects the resolved secret into os.environ for the duration of the +# call, so the subprocess inherits it — no manual env mapping needed. +@tool(credentials=["GH_TOKEN"]) def gh_list_prs(repo: str, state: str = "open") -> dict: """List pull requests for a GitHub repo using the gh CLI. @@ -39,7 +40,6 @@ def gh_list_prs(repo: str, state: str = "open") -> dict: ["gh", "pr", "list", "--repo", repo, "--state", state, "--limit", "10", "--json", "number,title,author,createdAt,url"], capture_output=True, text=True, timeout=15, - env={**os.environ, "GH_TOKEN": os.environ.get("GITHUB_TOKEN", "")}, ) if result.returncode != 0: return {"error": result.stderr.strip()} @@ -49,7 +49,7 @@ def gh_list_prs(repo: str, state: str = "open") -> dict: return {"repo": repo, "state": state, "pull_requests": prs} -@tool(credentials=["GITHUB_TOKEN"]) +@tool(credentials=["GH_TOKEN"]) def gh_create_pr(repo: str, title: str, body: str, head: str, base: str = "main") -> dict: """Create a pull request via the gh CLI. @@ -61,7 +61,6 @@ def gh_create_pr(repo: str, title: str, body: str, head: str, base: str = "main" "--title", title, "--body", body, "--head", head, "--base", base], capture_output=True, text=True, timeout=15, - env={**os.environ, "GH_TOKEN": os.environ.get("GITHUB_TOKEN", "")}, ) if result.returncode != 0: return {"error": result.stderr.strip()} @@ -108,7 +107,7 @@ def aws_get_caller_identity() -> dict: model=settings.llm_model, tools=[gh_list_prs, gh_create_pr, aws_list_s3_buckets, aws_get_caller_identity], cli_allowed_commands=["gh", "aws"], - credentials=["GITHUB_TOKEN", "GH_TOKEN", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"], + credentials=["GH_TOKEN", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"], instructions=( "You are a DevOps assistant. You can manage GitHub pull requests and " "inspect AWS resources. Always confirm destructive actions before proceeding." diff --git a/examples/agents/16g_credentials_framework_passthrough.py b/examples/agents/16g_credentials_framework_passthrough.py index b2238d44..75ae15ca 100644 --- a/examples/agents/16g_credentials_framework_passthrough.py +++ b/examples/agents/16g_credentials_framework_passthrough.py @@ -24,24 +24,28 @@ import os +from langchain_core.tools import tool as lc_tool + from conductor.ai.agents import AgentRuntime from settings import settings +# Module-level tool (spawn-safe: importable by qualified name). A +# tool defined inside the factory can't be resolved by a spawned worker. +@lc_tool +def check_github_auth() -> str: + """Check if GitHub authentication is available.""" + token = os.environ.get("GITHUB_TOKEN", "") + if token: + return f"GitHub token is set (starts with {token[:4]}...)" + return "GitHub token is NOT set" + + def create_langgraph_agent(): """Create a simple LangGraph agent with a tool that uses GITHUB_TOKEN.""" - from langchain_core.tools import tool as lc_tool from langchain_openai import ChatOpenAI from langgraph.prebuilt import create_react_agent - @lc_tool - def check_github_auth() -> str: - """Check if GitHub authentication is available.""" - token = os.environ.get("GITHUB_TOKEN", "") - if token: - return f"GitHub token is set (starts with {token[:4]}...)" - return "GitHub token is NOT set" - # Parse provider/model format model_str = settings.llm_model if "/" in model_str: diff --git a/examples/agents/16h_credentials_external_worker.py b/examples/agents/16h_credentials_external_worker.py index 4bba244d..92f79be4 100644 --- a/examples/agents/16h_credentials_external_worker.py +++ b/examples/agents/16h_credentials_external_worker.py @@ -61,9 +61,9 @@ def run_external_worker(): def github_lookup_worker(task: Task) -> TaskResult: username = task.input_data.get("username", "") - # resolve_credentials reads __agentspan_ctx__ from task input - # and calls the server to get the credential values - creds = resolve_credentials(task.input_data, ["GITHUB_TOKEN"]) + # resolve_credentials reads the host-resolved secret values the server + # delivered on Task.runtimeMetadata (declared via the tool's credentials). + creds = resolve_credentials(task, ["GITHUB_TOKEN"]) token = creds.get("GITHUB_TOKEN", "") headers = {"Authorization": f"Bearer {token}"} if token else {} @@ -107,5 +107,5 @@ def github_lookup_worker(task: Task) -> TaskResult: print(f" credentials: {agent.tools[0]._tool_def.credentials}") print() print("External worker pattern:") - print(" creds = resolve_credentials(task.input_data, ['GITHUB_TOKEN'])") + print(" creds = resolve_credentials(task, ['GITHUB_TOKEN'])") print(" token = creds['GITHUB_TOKEN']") diff --git a/examples/agents/58_scatter_gather.py b/examples/agents/58_scatter_gather.py index 4b9b9105..bc84e670 100644 --- a/examples/agents/58_scatter_gather.py +++ b/examples/agents/58_scatter_gather.py @@ -50,7 +50,7 @@ def search_knowledge_base(query: str) -> dict: researcher = Agent( name="researcher", - model="anthropic/claude-sonnet-4-20250514", + model=settings.llm_model, instructions=( "You are a country analyst. You will be given the name of a country. " "Use the search_knowledge_base tool ONCE to research that country, then " diff --git a/examples/agents/72_client_reconnect.py b/examples/agents/72_client_reconnect.py index 5469c4b1..5bb530aa 100644 --- a/examples/agents/72_client_reconnect.py +++ b/examples/agents/72_client_reconnect.py @@ -26,6 +26,7 @@ import json import os import signal +import sys import time from pathlib import Path diff --git a/examples/agents/73_worker_restart_recovery.py b/examples/agents/73_worker_restart_recovery.py index fd1b9d58..816c5871 100644 --- a/examples/agents/73_worker_restart_recovery.py +++ b/examples/agents/73_worker_restart_recovery.py @@ -24,6 +24,7 @@ import json import os import signal +import sys import time from datetime import UTC, datetime from pathlib import Path diff --git a/examples/agents/75_wait_for_message.py b/examples/agents/75_wait_for_message.py index a2e4607c..78ae5769 100644 --- a/examples/agents/75_wait_for_message.py +++ b/examples/agents/75_wait_for_message.py @@ -57,18 +57,26 @@ def execute_task(task: str) -> str: ), ) -with AgentRuntime() as runtime: - handle = runtime.start(agent, "Start listening for messages.") - print(f"Agent started: {handle.execution_id}") - print("Sending messages...\n") - - for msg in ["summarize quarterly report", "draft release notes", "check system health"]: - time.sleep(2) - print(f" -> sending: {msg!r}") - runtime.send_message(handle.execution_id, {"task": msg}) - - # Let the agent process all messages (~5-6s per message) - time.sleep(30) - handle.stop() - handle.join(timeout=30) - print("\nDone.") +def main() -> None: + with AgentRuntime() as runtime: + handle = runtime.start(agent, "Start listening for messages.") + print(f"Agent started: {handle.execution_id}") + print("Sending messages...\n") + + for msg in ["summarize quarterly report", "draft release notes", "check system health"]: + time.sleep(2) + print(f" -> sending: {msg!r}") + runtime.send_message(handle.execution_id, {"task": msg}) + + # Let the agent process all messages (~5-6s per message) + time.sleep(30) + handle.stop() + handle.join(timeout=30) + print("\nDone.") + + +# Guard the runtime block: spawned tool workers re-import this module, and +# without the guard they would re-run the orchestration (multiprocessing's +# "Safe importing of main module" error). +if __name__ == "__main__": + main() diff --git a/examples/agents/76_wait_for_message_streaming.py b/examples/agents/76_wait_for_message_streaming.py index 552a419a..4d5cf963 100644 --- a/examples/agents/76_wait_for_message_streaming.py +++ b/examples/agents/76_wait_for_message_streaming.py @@ -63,38 +63,46 @@ def respond(answer: str) -> str: "Write a one-line Python function that reverses a string", ] -with AgentRuntime() as runtime: - handle = runtime.start(agent, "Begin. Wait for your first instruction.") - print(f"Agent started: {handle.execution_id}\n") - - # Push messages from a background thread while we stream events on the main thread. - # Wait long enough between sends for the agent to finish processing each message. - # No sleep after the last send — handle.stream() on the main thread is already the - # barrier: it blocks until DONE, which only fires once the workflow reaches a - # terminal state (after stop() sets the flag and the current iteration completes). - def sender(): - for task in TASKS: - time.sleep(8) - print(f"\n [caller] sending -> {task!r}") - runtime.send_message(handle.execution_id, {"task": task}) - handle.stop() - - threading.Thread(target=sender, daemon=True).start() - - for event in handle.stream(): - if event.type == EventType.THINKING: - print(f" [thinking] {event.content}") - - elif event.type == EventType.TOOL_CALL and event.tool_name == "respond": - args = event.args or {} - print(f" [answer] {args.get('answer', '')}") - - elif event.type == EventType.WAITING: - print(f" [waiting] {event.content}") - - elif event.type == EventType.ERROR: - print(f" [error] {event.content}") - - elif event.type == EventType.DONE: - print(f"\nAgent finished: {event.output}") - break +def main() -> None: + with AgentRuntime() as runtime: + handle = runtime.start(agent, "Begin. Wait for your first instruction.") + print(f"Agent started: {handle.execution_id}\n") + + # Push messages from a background thread while we stream events on the main thread. + # Wait long enough between sends for the agent to finish processing each message. + # No sleep after the last send — handle.stream() on the main thread is already the + # barrier: it blocks until DONE, which only fires once the workflow reaches a + # terminal state (after stop() sets the flag and the current iteration completes). + def sender(): + for task in TASKS: + time.sleep(8) + print(f"\n [caller] sending -> {task!r}") + runtime.send_message(handle.execution_id, {"task": task}) + handle.stop() + + threading.Thread(target=sender, daemon=True).start() + + for event in handle.stream(): + if event.type == EventType.THINKING: + print(f" [thinking] {event.content}") + + elif event.type == EventType.TOOL_CALL and event.tool_name == "respond": + args = event.args or {} + print(f" [answer] {args.get('answer', '')}") + + elif event.type == EventType.WAITING: + print(f" [waiting] {event.content}") + + elif event.type == EventType.ERROR: + print(f" [error] {event.content}") + + elif event.type == EventType.DONE: + print(f"\nAgent finished: {event.output}") + break + + +# Guard the runtime block: spawned tool workers re-import this module, and +# without the guard they would re-run the orchestration (multiprocessing's +# "Safe importing of main module" error). +if __name__ == "__main__": + main() diff --git a/examples/agents/82_fan_out_fan_in.py b/examples/agents/82_fan_out_fan_in.py index f1a64754..04ae5d58 100644 --- a/examples/agents/82_fan_out_fan_in.py +++ b/examples/agents/82_fan_out_fan_in.py @@ -6,7 +6,7 @@ Demonstrates: - Fan-out: one Orchestrator agent sending the same task to N Worker agents - by calling runtime.send_message once per worker, all from a single @tool + by sending a WMQ message once per worker, all from a single @tool - Fan-in: each Worker sends its result into the Collector agent's WMQ so results arrive independently in any order - Three roles, five concurrently running workflows: @@ -15,12 +15,16 @@ Collector — receives 3×N results, builds side-by-side reports - Unique tool names per worker: Conductor routes tasks by definition name, so workers sharing a name would race for each other's tasks. Each worker - gets tools named submit_answer_ / stop_collector_. + gets tools named submit_answer_. + - Spawn-safety: worker tools are backed by *module-level* callables (not + closures over the runtime). Each rebuilds a workflow client from the + ambient Configuration inside its spawned worker process. The IPC dir is + shared across processes via an env var (a per-import tempfile.mkdtemp() + would give every spawned worker its own dir). - Filesystem IPC: * Workers write sentinels after submit_answer so main counts completions * Collector writes reports to files; main thread reads and prints them - * stop_* sentinels tell main all agents have cleanly shut down - - No time.sleep() to assume message delivery — all synchronisation via files + - No time.sleep() to assume message delivery — synchronisation via files Scenario: A research Orchestrator fans out each question to three Worker agents @@ -28,12 +32,12 @@ aggregates the three answers into a side-by-side comparison report. Requirements: - - AgentSpan server running at http://localhost:8080 - - AGENTSPAN_SERVER_URL=http://localhost:8080/api - - AGENTSPAN_LLM_MODEL=anthropic/claude-sonnet-4-20250514 + - AgentSpan server running (CONDUCTOR_SERVER_URL / AGENTSPAN_SERVER_URL) + - AGENTSPAN_LLM_MODEL set to a working model """ import json +import os import shutil import tempfile import time @@ -47,11 +51,18 @@ # Filesystem IPC # --------------------------------------------------------------------------- -_ipc_dir = Path(tempfile.mkdtemp(prefix="fan_out_fan_in_")) +# Workers run in *spawned* processes that re-import this module. Derive the IPC +# directory from an env var (set once in the parent, inherited by the spawned +# workers) so every process shares one directory — a per-import +# tempfile.mkdtemp() would give each worker its own dir and break the +# file-based synchronisation. +_ipc_dir = Path( + os.environ.setdefault("FAN_OUT_FAN_IN_IPC_DIR", tempfile.mkdtemp(prefix="fan_out_fan_in_")) +) _ANSWERS_DIR = _ipc_dir / "answers" # one sentinel per submitted answer _REPORTS_DIR = _ipc_dir / "reports" # one JSON file per aggregated report -_ANSWERS_DIR.mkdir() -_REPORTS_DIR.mkdir() +_ANSWERS_DIR.mkdir(parents=True, exist_ok=True) +_REPORTS_DIR.mkdir(parents=True, exist_ok=True) NUM_WORKERS = 3 WORKER_NAMES = ["alpha", "beta", "gamma"] @@ -63,6 +74,61 @@ ] +# --------------------------------------------------------------------------- +# Spawn-safe worker callables — module-level (not closures over the runtime). +# Each rebuilds a workflow client from the ambient Configuration so it can push +# a WMQ message from inside its spawned worker process. +# --------------------------------------------------------------------------- + + +def _workflow_client(): + from conductor.client.configuration.configuration import Configuration + from conductor.client.orkes_clients import OrkesClients + + return OrkesClients(Configuration()).get_workflow_client() + + +class _SubmitAnswer: + """Send this worker's answer to the Collector, then write a sentinel.""" + + def __init__(self, worker_name: str, collector_id: str) -> None: + self.worker_name = worker_name + self.collector_id = collector_id + + def __call__(self, question: str, answer: str) -> str: + """Send this worker's answer to the Collector and write a completion sentinel.""" + _workflow_client().send_message( + self.collector_id, + {"question": question, "worker_name": self.worker_name, "answer": answer}, + ) + (_ANSWERS_DIR / f"{self.worker_name}_{time.time_ns()}.done").touch() + return "submitted" + + +class _FanOut: + """Broadcast a question to all worker agents simultaneously.""" + + def __init__(self, worker_ids: list) -> None: + self.worker_ids = list(worker_ids) + + def __call__(self, question: str) -> str: + """Broadcast the question to all worker agents simultaneously.""" + wf = _workflow_client() + for wid in self.worker_ids: + wf.send_message(wid, {"question": question}) + return f"broadcasted to {len(self.worker_ids)} workers" + + +@tool +def save_report(question: str, report: str) -> str: + """Write the aggregated side-by-side report to a file for the main thread to read.""" + safe = question[:40].replace(" ", "_").replace("?", "") + (_REPORTS_DIR / f"{time.time_ns()}_{safe}.json").write_text( + json.dumps({"question": question, "report": report}) + ) + return "saved" + + # --------------------------------------------------------------------------- # Collector agent — fan-in # --------------------------------------------------------------------------- @@ -76,15 +142,6 @@ def build_collector() -> Agent: ), ) - @tool - def save_report(question: str, report: str) -> str: - """Write the aggregated side-by-side report to a file for the main thread to read.""" - safe = question[:40].replace(" ", "_").replace("?", "") - (_REPORTS_DIR / f"{time.time_ns()}_{safe}.json").write_text( - json.dumps({"question": question, "report": report}) - ) - return "saved" - return Agent( name="collector_agent", model=settings.llm_model, @@ -110,7 +167,7 @@ def save_report(question: str, report: str) -> str: # Worker agents — unique tool names per worker to avoid Conductor name collision # --------------------------------------------------------------------------- -def build_worker(worker_name: str, runtime: AgentRuntime, collector_id: str) -> Agent: +def build_worker(worker_name: str, collector_id: str) -> Agent: receive_task = wait_for_message_tool( name=f"receive_task_{worker_name}", description=f"Wait for the next task for worker {worker_name}. Payload: {{question}}.", @@ -118,16 +175,9 @@ def build_worker(worker_name: str, runtime: AgentRuntime, collector_id: str) -> # Tool names must be unique across all workers so Conductor routes each # task to the correct worker process. - @tool(name=f"submit_answer_{worker_name}") - def submit_answer(question: str, answer: str) -> str: - """Send this worker's answer to the Collector and write a completion sentinel.""" - runtime.send_message(collector_id, { - "question": question, - "worker_name": worker_name, - "answer": answer, - }) - (_ANSWERS_DIR / f"{worker_name}_{time.time_ns()}.done").touch() - return "submitted" + submit_answer = tool(name=f"submit_answer_{worker_name}")( + _SubmitAnswer(worker_name, collector_id) + ) return Agent( name=f"worker_{worker_name}", @@ -150,18 +200,13 @@ def submit_answer(question: str, answer: str) -> str: # Orchestrator agent # --------------------------------------------------------------------------- -def build_orchestrator(runtime: AgentRuntime, worker_ids: list) -> Agent: +def build_orchestrator(worker_ids: list) -> Agent: receive_question = wait_for_message_tool( name="receive_question", description="Wait for the next question to fan out.", ) - @tool - def fan_out(question: str) -> str: - """Broadcast the question to all worker agents simultaneously.""" - for wid in worker_ids: - runtime.send_message(wid, {"question": question}) - return f"broadcasted to {len(worker_ids)} workers" + fan_out = tool(name="fan_out")(_FanOut(worker_ids)) return Agent( name="orchestrator_agent", @@ -182,79 +227,84 @@ def fan_out(question: str) -> str: # Main # --------------------------------------------------------------------------- -total_answers = len(QUESTIONS) * NUM_WORKERS - -try: - with AgentRuntime() as runtime: - # Start Collector first — workers need its ID. - collector_handle = runtime.start(build_collector(), "Begin. Wait for worker results.") - collector_id = collector_handle.execution_id - print(f"Collector started: {collector_id}") - - # Start Workers — Orchestrator needs their IDs. - worker_ids: list = [] - worker_handles: list = [] - for name in WORKER_NAMES: - wh = runtime.start( - build_worker(name, runtime, collector_id), - f"Begin. You are worker {name.upper()}. Wait for tasks.", +def main() -> None: + total_answers = len(QUESTIONS) * NUM_WORKERS + + try: + with AgentRuntime() as runtime: + # Start Collector first — workers need its ID. + collector_handle = runtime.start(build_collector(), "Begin. Wait for worker results.") + collector_id = collector_handle.execution_id + print(f"Collector started: {collector_id}") + + # Start Workers — Orchestrator needs their IDs. + worker_ids: list = [] + worker_handles: list = [] + for name in WORKER_NAMES: + wh = runtime.start( + build_worker(name, collector_id), + f"Begin. You are worker {name.upper()}. Wait for tasks.", + ) + worker_ids.append(wh.execution_id) + worker_handles.append(wh) + print(f"Worker {name:5s} started: {wh.execution_id}") + + # Start Orchestrator last. + orch_handle = runtime.start( + build_orchestrator(worker_ids), + "Begin. Wait for questions to fan out.", ) - worker_ids.append(wh.execution_id) - worker_handles.append(wh) - print(f"Worker {name:5s} started: {wh.execution_id}") - - # Start Orchestrator last. - orch_handle = runtime.start( - build_orchestrator(runtime, worker_ids), - "Begin. Wait for questions to fan out.", - ) - orchestrator_id = orch_handle.execution_id - print(f"Orchestrator started: {orchestrator_id}\n") - - # Give all agents time to reach their first wait call. - time.sleep(5) - - print(f"Fanning out {len(QUESTIONS)} question(s) to {NUM_WORKERS} workers each " - f"({total_answers} total answers expected)...\n") - for q in QUESTIONS: - print(f" → {q[:70]}") - runtime.send_message(orchestrator_id, {"question": q}) - - # Tail answers as they arrive. - print(f"\nWaiting for {total_answers} answers and {len(QUESTIONS)} reports...\n") - seen_answers: set = set() - seen_reports: set = set() - - while len(seen_reports) < len(QUESTIONS): - # Print new answer sentinels. - for p in sorted(_ANSWERS_DIR.iterdir()): - if p.name not in seen_answers: - worker = p.name.split("_")[0] - print(f" [answer received] worker:{worker}") - seen_answers.add(p.name) - - # Print new reports as they appear. - for p in sorted(_REPORTS_DIR.iterdir()): - if p.name not in seen_reports: - data = json.loads(p.read_text()) - print(f"\n ── {data['question'][:60]}… ──") - print(f" {data['report']}\n") - seen_reports.add(p.name) - - time.sleep(0.1) - - print(f"All {len(QUESTIONS)} reports received. Shutting down...\n") - - # Deterministic stop — no stop-handling instructions needed. - orch_handle.stop() - for wh in worker_handles: - wh.stop() - collector_handle.stop() - orch_handle.join(timeout=60) - for wh in worker_handles: - wh.join(timeout=30) - collector_handle.join(timeout=30) - - print("Done.") -finally: - shutil.rmtree(_ipc_dir, ignore_errors=True) + orchestrator_id = orch_handle.execution_id + print(f"Orchestrator started: {orchestrator_id}\n") + + # Give all agents time to reach their first wait call. + time.sleep(5) + + print(f"Fanning out {len(QUESTIONS)} question(s) to {NUM_WORKERS} workers each " + f"({total_answers} total answers expected)...\n") + for q in QUESTIONS: + print(f" → {q[:70]}") + runtime.send_message(orchestrator_id, {"question": q}) + + # Tail answers as they arrive. + print(f"\nWaiting for {total_answers} answers and {len(QUESTIONS)} reports...\n") + seen_answers: set = set() + seen_reports: set = set() + + while len(seen_reports) < len(QUESTIONS): + # Print new answer sentinels. + for p in sorted(_ANSWERS_DIR.iterdir()): + if p.name not in seen_answers: + worker = p.name.split("_")[0] + print(f" [answer received] worker:{worker}") + seen_answers.add(p.name) + + # Print new reports as they appear. + for p in sorted(_REPORTS_DIR.iterdir()): + if p.name not in seen_reports: + data = json.loads(p.read_text()) + print(f"\n ── {data['question'][:60]}… ──") + print(f" {data['report']}\n") + seen_reports.add(p.name) + + time.sleep(0.1) + + print(f"All {len(QUESTIONS)} reports received. Shutting down...\n") + + # Deterministic stop — no stop-handling instructions needed. + orch_handle.stop() + for wh in worker_handles: + wh.stop() + collector_handle.stop() + orch_handle.join(timeout=60) + for wh in worker_handles: + wh.join(timeout=30) + collector_handle.join(timeout=30) + + print("Done.") + finally: + shutil.rmtree(_ipc_dir, ignore_errors=True) + + +if __name__ == "__main__": + main() diff --git a/examples/agents/83_stateful_resume.py b/examples/agents/83_stateful_resume.py index ab994b1f..6bc0b708 100644 --- a/examples/agents/83_stateful_resume.py +++ b/examples/agents/83_stateful_resume.py @@ -76,57 +76,64 @@ def execute_task(task: str) -> str: # ── Phase 1: Start, interact, close runtime ───────────────────────────── -print("=" * 60) -print("Phase 1: Start agent, send a task, then close runtime") -print("=" * 60) - -with AgentRuntime() as runtime: - handle = runtime.start(agent, "Start listening for messages.") - execution_id = handle.execution_id - print(f"\nAgent started: {execution_id}") - print(f"Domain (run_id): {handle.run_id}") - - # Save execution_id for Phase 2 - with open(SESSION_FILE, "w") as f: - f.write(execution_id) - print(f"Saved execution_id to {SESSION_FILE}") - - # Send a task and let the agent process it - time.sleep(3) - print("\nSending task: 'summarize quarterly report'") - runtime.send_message(execution_id, {"task": "summarize quarterly report"}) - time.sleep(8) - -print("\nRuntime closed — workers are dead, workflow persists on server.\n") - - -# ── Phase 2: Resume with a fresh runtime ───────────────────────────────── - -print("=" * 60) -print("Phase 2: Resume with a fresh runtime") -print("=" * 60) - -# Load the execution_id (in a real scenario, this could be from a database, -# a file, or passed as a CLI argument) -with open(SESSION_FILE) as f: - saved_execution_id = f.read().strip() - -print(f"\nResuming execution: {saved_execution_id}") - -with AgentRuntime() as runtime: - # resume() fetches the workflow from the server, reads taskToDomain, - # and re-registers workers under the original domain. - handle = runtime.resume(saved_execution_id, agent) - print(f"Resumed! Domain (run_id): {handle.run_id}") - - # Send another task — workers are back and polling under the correct domain - time.sleep(3) - print("\nSending task: 'check system health'") - runtime.send_message(saved_execution_id, {"task": "check system health"}) - time.sleep(8) - - # Clean shutdown - print("\nSending stop signal...") - runtime.send_message(saved_execution_id, {"stop": True}) - handle.join(timeout=30) - print("\nDone — same workflow, same domain, seamless resume.") +def main() -> None: + print("=" * 60) + print("Phase 1: Start agent, send a task, then close runtime") + print("=" * 60) + + with AgentRuntime() as runtime: + handle = runtime.start(agent, "Start listening for messages.") + execution_id = handle.execution_id + print(f"\nAgent started: {execution_id}") + print(f"Domain (run_id): {handle.run_id}") + + # Save execution_id for Phase 2 + with open(SESSION_FILE, "w") as f: + f.write(execution_id) + print(f"Saved execution_id to {SESSION_FILE}") + + # Send a task and let the agent process it + time.sleep(3) + print("\nSending task: 'summarize quarterly report'") + runtime.send_message(execution_id, {"task": "summarize quarterly report"}) + time.sleep(8) + + print("\nRuntime closed — workers are dead, workflow persists on server.\n") + + # ── Phase 2: Resume with a fresh runtime ───────────────────────────── + + print("=" * 60) + print("Phase 2: Resume with a fresh runtime") + print("=" * 60) + + # Load the execution_id (in a real scenario, this could be from a database, + # a file, or passed as a CLI argument) + with open(SESSION_FILE) as f: + saved_execution_id = f.read().strip() + + print(f"\nResuming execution: {saved_execution_id}") + + with AgentRuntime() as runtime: + # resume() fetches the workflow from the server, reads taskToDomain, + # and re-registers workers under the original domain. + handle = runtime.resume(saved_execution_id, agent) + print(f"Resumed! Domain (run_id): {handle.run_id}") + + # Send another task — workers are back and polling under the correct domain + time.sleep(3) + print("\nSending task: 'check system health'") + runtime.send_message(saved_execution_id, {"task": "check system health"}) + time.sleep(8) + + # Clean shutdown + print("\nSending stop signal...") + runtime.send_message(saved_execution_id, {"stop": True}) + handle.join(timeout=30) + print("\nDone — same workflow, same domain, seamless resume.") + + +# Guard the runtime block: spawned tool workers re-import this module, and +# without the guard they would re-run the orchestration (multiprocessing's +# "Safe importing of main module" error). +if __name__ == "__main__": + main() diff --git a/examples/agents/84_deterministic_stop.py b/examples/agents/84_deterministic_stop.py index a202f390..79bf35a8 100644 --- a/examples/agents/84_deterministic_stop.py +++ b/examples/agents/84_deterministic_stop.py @@ -79,26 +79,34 @@ def process_task(task: str) -> str: "send status summary to team", ] -with AgentRuntime() as runtime: - handle = runtime.start(agent, "Begin processing tasks.") - print(f"Agent started: {handle.execution_id}") - print(f"Domain: {handle.run_id}\n") - - # Wait for agent to reach its first wait_for_task call - time.sleep(3) - - # Send tasks - for task in TASKS: - print(f" → sending: {task!r}") - runtime.send_message(handle.execution_id, {"task": task}) - time.sleep(6) - - # Deterministic stop — no instructions, no LLM cooperation needed - print("\nSending stop signal (deterministic)...") - handle.stop() - - # Wait for the agent to complete gracefully - result = handle.join(timeout=30) - print(f"\nStatus: {result.status}") # COMPLETED (not TERMINATED) - print(f"Output: {result.output}") - print("Done.") +def main() -> None: + with AgentRuntime() as runtime: + handle = runtime.start(agent, "Begin processing tasks.") + print(f"Agent started: {handle.execution_id}") + print(f"Domain: {handle.run_id}\n") + + # Wait for agent to reach its first wait_for_task call + time.sleep(3) + + # Send tasks + for task in TASKS: + print(f" → sending: {task!r}") + runtime.send_message(handle.execution_id, {"task": task}) + time.sleep(6) + + # Deterministic stop — no instructions, no LLM cooperation needed + print("\nSending stop signal (deterministic)...") + handle.stop() + + # Wait for the agent to complete gracefully + result = handle.join(timeout=30) + print(f"\nStatus: {result.status}") # COMPLETED (not TERMINATED) + print(f"Output: {result.output}") + print("Done.") + + +# Guard the runtime block: spawned tool workers re-import this module, and +# without the guard they would re-run the orchestration (multiprocessing's +# "Safe importing of main module" error). +if __name__ == "__main__": + main() diff --git a/examples/agents/92_openai_agents_compat.py b/examples/agents/92_openai_agents_compat.py index bde623bf..b6853770 100644 --- a/examples/agents/92_openai_agents_compat.py +++ b/examples/agents/92_openai_agents_compat.py @@ -42,39 +42,46 @@ import argparse +from conductor.ai import function_tool -# ── Pattern B — pure Agentspan, no openai-agents dependency ──────────────── -def run_pattern_b() -> None: - """Run using Agentspan's own Agent and function_tool (same result).""" - from conductor.ai import Runner, function_tool - from conductor.ai.agents import Agent - from settings import settings +# Module-level tools (spawn-safe: importable by qualified name). Workers are +# served in spawned processes, so tools must NOT be functions defined +# inside run_pattern_*() — those can't be imported by the spawn child. +@function_tool +def get_weather(city: str) -> str: + """Return the current weather for a city. - @function_tool - def get_weather(city: str) -> str: - """Return the current weather for a city. + Args: + city: Name of the city. + """ + return f"72°F and sunny in {city}" - Args: - city: Name of the city. - """ - return f"72°F and sunny in {city}" - @function_tool - def get_time(timezone: str) -> str: - """Return the current time in a timezone. +@function_tool +def get_time(timezone: str) -> str: + """Return the current time in a timezone. - Args: - timezone: IANA timezone name (e.g. 'America/New_York'). - """ - from datetime import datetime - import zoneinfo + Args: + timezone: IANA timezone name (e.g. 'America/New_York'). + """ + from datetime import datetime + import zoneinfo - try: - tz = zoneinfo.ZoneInfo(timezone) - return datetime.now(tz).strftime("%H:%M %Z") - except Exception: - return f"Unknown timezone: {timezone}" + try: + tz = zoneinfo.ZoneInfo(timezone) + return datetime.now(tz).strftime("%H:%M %Z") + except Exception: + return f"Unknown timezone: {timezone}" + + +# ── Pattern B — pure Agentspan, no openai-agents dependency ──────────────── + +def run_pattern_b() -> None: + """Run using Agentspan's own Agent and function_tool (same result).""" + from conductor.ai import Runner + from conductor.ai.agents import Agent + from settings import settings agent = Agent( name="weather_assistant_b", diff --git a/examples/agents/hello_world_agent_schedule.py b/examples/agents/hello_world_agent_schedule.py index c2f11103..acc82ae8 100644 --- a/examples/agents/hello_world_agent_schedule.py +++ b/examples/agents/hello_world_agent_schedule.py @@ -23,9 +23,14 @@ import requests from conductor.ai.agents import Agent, AgentRuntime +from conductor.client.configuration.configuration import Configuration from conductor.ai.agents.schedule import Schedule -SERVER = "http://localhost:8080/api" +SERVER = ( + os.environ.get("CONDUCTOR_SERVER_URL") + or os.environ.get("AGENTSPAN_SERVER_URL") + or "http://localhost:8080/api" +) MODEL = os.environ.get("AGENTSPAN_MODEL", "anthropic/claude-sonnet-4-6") @@ -75,7 +80,7 @@ def main() -> None: # 2. Deploy and attach a 5-second schedule in one call. # (Conductor uses 6-field Quartz cron with optional seconds.) - with AgentRuntime(server_url=SERVER) as rt: + with AgentRuntime(Configuration(server_api_url=SERVER)) as rt: rt.deploy( agent, schedules=[ diff --git a/examples/agents/hello_world_every_second.py b/examples/agents/hello_world_every_second.py index 9fafaee1..6f072636 100644 --- a/examples/agents/hello_world_every_second.py +++ b/examples/agents/hello_world_every_second.py @@ -18,6 +18,7 @@ import requests from conductor.ai.agents import Agent, AgentRuntime +from conductor.client.configuration.configuration import Configuration from conductor.ai.agents.schedule import Schedule SERVER = "http://localhost:8080/api" @@ -66,7 +67,7 @@ def main() -> None: ), ) - with AgentRuntime(server_url=SERVER) as rt: + with AgentRuntime(Configuration(server_api_url=SERVER)) as rt: rt.deploy( agent, schedules=[ diff --git a/examples/agents/hello_world_schedule.py b/examples/agents/hello_world_schedule.py index 7dfbe491..b9947294 100644 --- a/examples/agents/hello_world_schedule.py +++ b/examples/agents/hello_world_schedule.py @@ -19,6 +19,7 @@ from __future__ import annotations +import os import time import uuid @@ -28,7 +29,11 @@ from conductor.ai.agents.schedule import Schedule -CONDUCTOR_API = "http://localhost:8080/api" +CONDUCTOR_API = ( + os.environ.get("CONDUCTOR_SERVER_URL") + or os.environ.get("AGENTSPAN_SERVER_URL") + or "http://localhost:8080/api" +) def register_hello_world_workflow(name: str) -> None: @@ -109,7 +114,7 @@ def main() -> None: # 2. Build the scheduler client against the scheduler-enabled # Conductor instance. - clients = OrkesClients(configuration=Configuration(base_url="http://localhost:8080")) + clients = OrkesClients(configuration=Configuration(server_api_url=CONDUCTOR_API)) sched_client = clients.get_scheduler_client() # 3. Declarative deploy: one schedule, fires every 2 seconds. diff --git a/examples/agents/run_all_examples.py b/examples/agents/run_all_examples.py new file mode 100644 index 00000000..e25d9b26 --- /dev/null +++ b/examples/agents/run_all_examples.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025 Agentspan +# Licensed under the MIT License. See LICENSE file in the project root for details. +"""Run every agent example against a live Agentspan server and report status. + +"Works fine" means: + * PASS — process exits 0 within the timeout (no compile/import/runtime error, + no hang). + * ERROR — non-zero exit: import/compile error, traceback, AgentAPIError, + SpawnSafetyError, etc. + * HUNG — did not finish within the per-example timeout (a stuck/never-ending + agent). The whole process group is killed so spawned tool workers + don't linger. + * SKIP — deliberately not run: interactive (reads stdin), a long-running + daemon by design (serve / schedule-forever / kafka / dashboard), or + requires external infra we don't provide (MCP server, Docker, + Jupyter, Slack, non-configured LLM provider). + +Runs up to N examples concurrently (default 4). Emits an ASCII table to stdout +and a color-coded HTML report. + +Usage: + python run_all_examples.py [--jobs 4] [--timeout 180] [--dry-run] \ + [--only PATTERN] [--out report.html] + +Env (with sensible defaults for the local dev server on :6767): + CONDUCTOR_SERVER_URL / AGENTSPAN_SERVER_URL server API base + AGENTSPAN_LLM_MODEL model steered to a working provider +""" +from __future__ import annotations + +import argparse +import concurrent.futures +import html +import os +import re +import signal +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO = HERE.parents[1] # examples/agents -> examples -> repo root + +DEFAULT_SERVER = "http://localhost:6767/api" +DEFAULT_MODEL = "openai/gpt-4o-mini" + +# ── Skip lists (judgment) ──────────────────────────────────────────────── +# Long-running by design or interactive — would legitimately never terminate. +DAEMON_SKIP = { + "12_long_running.py": "long-running by design", + "63b_serve.py": "serve() worker daemon (blocks forever)", + "63d_serve_from_package.py": "serve() worker daemon (blocks forever)", + "63e_run_monitoring.py": "live monitoring loop", + "77_kafka_consumer_agent.py": "kafka consumer daemon (+ needs Kafka)", + "79_agent_message_bus.py": "message-bus daemon", + "80_live_dashboard.py": "interactive live dashboard", + "81_chat_repl.py": "interactive REPL", + "82b_coding_agent_tui.py": "interactive TUI", + "hello_world_every_second.py": "schedules every 1s + polls forever", + "72_client_reconnect.py": "reconnect demo: run_once waits for a manual approval/resume flow", +} +# Require external infra / providers not available in this environment. +INFRA_SKIP = { + "04_mcp_weather.py": "needs an MCP server", + "04_http_and_mcp_tools.py": "needs an MCP server", + "16f_credentials_mcp_tool.py": "needs an MCP server", + "107_pac_mcp_proof.py": "needs an MCP server", + "39a_docker_code_execution.py": "needs Docker", + "39b_jupyter_code_execution.py": "needs a Jupyter kernel", + "39c_serverless_code_execution.py": "needs a serverless code sandbox", + "91_slack_autofix_agent.py": "needs Slack + webhook", + "16k_credentials_google_adk.py": "needs Google ADK / Gemini creds", + "16f_credentials_mcp_tool.py": "needs an MCP server", + "97_openai_runner_sandbox.py": "needs a Docker sandbox", + "61a_github_coding_agent_claude_code.py": "needs the claude-code-sdk package", + "116_ocg_subagent.py": "needs an OCG instance (OCG_INSTANCE_URL)", + "117_ocg_direct_tools.py": "needs an OCG instance (OCG_INSTANCE_URL)", + "100_issue_fixer_agent.py": "requires CLI arguments", + "70_ce_support_agent.py": "requires CLI arguments (ticket id)", + "33_external_workers.py": "needs external worker processes (referenced via @tool(external=True))", + "61_github_coding_agent_chained.py": "needs GitHub + an external trigger from another terminal", + "63c_run_by_name.py": "needs 63b_serve.py workers running in another terminal", +} +# Not examples (helpers, tests, utilities). +NOT_EXAMPLES = { + "run_all_examples.py", + "settings.py", + "kitchen_sink_helpers.py", + "dump_agent_configs.py", + "testing_multi_agent_correctness.py", + "_issue_fixer_tools.py", + "_issue_fixer_instructions.py", +} + +STATUS_ORDER = {"ERROR": 0, "HUNG": 1, "PASS": 2, "SKIP": 3} + + +@dataclass +class Result: + name: str + status: str # PASS | ERROR | HUNG | SKIP + duration: float + detail: str = "" + + +def discover() -> list[Path]: + files = [] + for p in sorted(HERE.glob("*.py")): + if p.name in NOT_EXAMPLES or p.name.startswith("_") or p.name.startswith("test_"): + continue + files.append(p) + return files + + +def classify_skip(path: Path) -> str | None: + if path.name in DAEMON_SKIP: + return DAEMON_SKIP[path.name] + if path.name in INFRA_SKIP: + return INFRA_SKIP[path.name] + try: + src = path.read_text(encoding="utf-8", errors="ignore") + except Exception: + src = "" + # Interactive: reads from stdin at runtime. + if re.search(r"(? str: + """Return a short signature of the failure from captured output.""" + lines = [ln.rstrip() for ln in output.splitlines() if ln.strip()] + # Prefer the last "SomethingError: msg" line (the actual raised exception). + for ln in reversed(lines): + m = re.match(r"^([\w.]*(?:Error|Exception|Failure))\b:?\s*(.*)$", ln.strip()) + if m: + return (ln.strip())[:200] + # Fall back to the last non-empty line. + return (lines[-1][:200] if lines else "non-zero exit, no output") + + +def run_one(path: Path, env: dict, timeout: int) -> Result: + skip = classify_skip(path) + if skip: + return Result(path.name, "SKIP", 0.0, skip) + + start = time.monotonic() + try: + proc = subprocess.Popen( + [sys.executable, str(path)], + cwd=str(HERE), + env=env, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + start_new_session=True, # own process group -> kill children on timeout + ) + except Exception as e: # pragma: no cover + return Result(path.name, "ERROR", 0.0, f"launch failed: {e}") + + try: + out, _ = proc.communicate(timeout=timeout) + dur = time.monotonic() - start + if proc.returncode == 0: + return Result(path.name, "PASS", dur) + return Result(path.name, "ERROR", dur, extract_error(out or "")) + except subprocess.TimeoutExpired: + dur = time.monotonic() - start + # Kill the whole process group (agent + spawned tool workers). + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except Exception: + pass + try: + proc.communicate(timeout=10) + except Exception: + pass + return Result(path.name, "HUNG", dur, f"no exit within {timeout}s") + + +def write_html(results: list[Result], out: Path, meta: dict) -> None: + counts = {s: sum(1 for r in results if r.status == s) for s in ("PASS", "ERROR", "HUNG", "SKIP")} + colors = {"PASS": "#1a7f37", "ERROR": "#cf222e", "HUNG": "#bc4c00", "SKIP": "#57606a"} + bg = {"PASS": "#eaf6ec", "ERROR": "#ffebe9", "HUNG": "#fff1e5", "SKIP": "#f6f8fa"} + rows = [] + for r in sorted(results, key=lambda r: (STATUS_ORDER[r.status], r.name)): + rows.append( + f'' + f'{html.escape(r.name)}' + f'{r.status}' + f'{r.duration:.1f}s' + f'{html.escape(r.detail)}' + ) + total = len(results) + summary = " · ".join( + f'{s}: {counts[s]}' + for s in ("PASS", "ERROR", "HUNG", "SKIP") + ) + doc = f""" +Agent examples — run report + +

Agent examples — run report

+
{html.escape(meta['when'])} · server {html.escape(meta['server'])} + · model {html.escape(meta['model'])} + · jobs {meta['jobs']} · timeout {meta['timeout']}s · {total} examples · {meta['wall']:.0f}s wall
+
{summary}
+ +{''.join(rows)}
ExampleStatusDurationDetail
+""" + out.write_text(doc, encoding="utf-8") + + +def print_table(results: list[Result]) -> None: + width = max((len(r.name) for r in results), default=20) + print(f"\n{'EXAMPLE':<{width}} {'STATUS':<6} {'DUR':>7} DETAIL") + print("-" * (width + 40)) + for r in sorted(results, key=lambda r: (STATUS_ORDER[r.status], r.name)): + print(f"{r.name:<{width}} {r.status:<6} {r.duration:>6.1f}s {r.detail[:70]}") + counts = {s: sum(1 for r in results if r.status == s) for s in ("PASS", "ERROR", "HUNG", "SKIP")} + print("-" * (width + 40)) + print(f"TOTAL {len(results)} | " + " ".join(f"{s}={counts[s]}" for s in ("PASS", "ERROR", "HUNG", "SKIP"))) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--jobs", type=int, default=4) + ap.add_argument("--timeout", type=int, default=180) + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--only", default=None, help="substring filter on filename") + ap.add_argument("--out", default=str(HERE / "run_report.html")) + args = ap.parse_args() + + server = os.environ.get("CONDUCTOR_SERVER_URL") or os.environ.get("AGENTSPAN_SERVER_URL") or DEFAULT_SERVER + model = os.environ.get("AGENTSPAN_LLM_MODEL", DEFAULT_MODEL) + + env = dict(os.environ) + env["CONDUCTOR_SERVER_URL"] = server + env["AGENTSPAN_SERVER_URL"] = server + env["AGENTSPAN_LLM_MODEL"] = model + env["PYTHONPATH"] = os.pathsep.join([str(REPO / "src"), str(HERE), env.get("PYTHONPATH", "")]) + env["PYTHONUNBUFFERED"] = "1" + + examples = discover() + if args.only: + examples = [p for p in examples if args.only in p.name] + + if args.dry_run: + run, skip = [], [] + for p in examples: + s = classify_skip(p) + (skip if s else run).append((p.name, s)) + print(f"WOULD RUN ({len(run)}):") + for n, _ in run: + print(f" run {n}") + print(f"\nWOULD SKIP ({len(skip)}):") + for n, why in skip: + print(f" skip {n:<40} {why}") + return 0 + + print(f"Running {len(examples)} examples · jobs={args.jobs} · timeout={args.timeout}s · server={server} · model={model}") + wall_start = time.monotonic() + results: list[Result] = [] + with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as ex: + futs = {ex.submit(run_one, p, env, args.timeout): p for p in examples} + done = 0 + for fut in concurrent.futures.as_completed(futs): + r = fut.result() + results.append(r) + done += 1 + print(f"[{done}/{len(examples)}] {r.status:<6} {r.name} ({r.duration:.0f}s) {r.detail[:60]}") + + wall = time.monotonic() - wall_start + print_table(results) + meta = { + "when": time.strftime("%Y-%m-%d %H:%M:%S"), + "server": server, "model": model, "jobs": args.jobs, + "timeout": args.timeout, "wall": wall, + } + out = Path(args.out) + write_html(results, out, meta) + print(f"\nHTML report: {out}") + # Exit non-zero if anything genuinely failed or hung. + bad = sum(1 for r in results if r.status in ("ERROR", "HUNG")) + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/agents/settings.py b/examples/agents/settings.py index 659a9c8e..2f03abe0 100644 --- a/examples/agents/settings.py +++ b/examples/agents/settings.py @@ -21,7 +21,7 @@ @dataclass class Settings: - llm_model: str = "anthropic/claude-sonnet-4-6" + llm_model: str = "openai/gpt-4o" secondary_llm_model: str = "openai/gpt-4o" @classmethod diff --git a/src/conductor/ai/agents/__init__.py b/src/conductor/ai/agents/__init__.py index f1517e71..6bdeda04 100644 --- a/src/conductor/ai/agents/__init__.py +++ b/src/conductor/ai/agents/__init__.py @@ -127,6 +127,7 @@ def get_weather(city: str) -> str: # Runtime (for context manager and advanced usage) from conductor.ai.agents.runtime.config import AgentConfig +from conductor.ai.agents.run_settings import RunSettings # Credential management from conductor.ai.agents.runtime.credentials.accessor import get_secret @@ -147,33 +148,28 @@ def get_weather(city: str) -> str: ) -def resolve_credentials(input_data: dict, names: list) -> dict: - """Resolve credentials from Conductor task input data. +def resolve_credentials(task: object, names: list) -> dict: + """Resolve declared credentials for an external (non-``@tool``) worker. - For external workers that need to resolve credentials from the - agentspan credential store. Extracts the execution token from - ``__agentspan_ctx__`` in the task input and calls the server. + Secured hosts resolve a worker's declared ``TaskDef.runtimeMetadata`` secret + names at poll time and deliver the values on the wire-only + ``Task.runtimeMetadata`` (conductor-oss PR #1255). This reads those values off + the polled ``task`` — there is no separate fetch call or execution token. Args: - input_data: The Conductor task's ``input_data`` dict. - names: Credential names to resolve. + task: The Conductor ``Task`` object delivered to the worker. + names: Credential names to resolve (must be declared on the tool/agent so + the server resolves them). Returns: Dict mapping credential name to resolved plaintext value. - """ - from conductor.ai.agents.runtime.config import AgentConfig - from conductor.ai.agents.runtime.credentials.fetcher import WorkerCredentialFetcher - token = None - ctx = input_data.get("__agentspan_ctx__") - if isinstance(ctx, dict): - token = ctx.get("execution_token") - elif isinstance(ctx, str): - token = ctx + Raises: + CredentialNotFoundError: If any declared name was not delivered by the host. + """ + from conductor.ai.agents.runtime._dispatch import _resolve_secrets_from_task - config = AgentConfig.from_env() - fetcher = WorkerCredentialFetcher(server_url=config.server_url) - return fetcher.fetch(token, names) + return _resolve_secrets_from_task(task, names) # Agent discovery @@ -250,6 +246,7 @@ def resolve_credentials(input_data: dict, names: list) -> dict: "AgentRuntime", "VALID_RETRY_POLICIES", "AgentConfig", + "RunSettings", # Extended agent types "GPTAssistantAgent", # Tools diff --git a/src/conductor/ai/agents/_internal/agent_http.py b/src/conductor/ai/agents/_internal/agent_http.py new file mode 100644 index 00000000..2c0e06c7 --- /dev/null +++ b/src/conductor/ai/agents/_internal/agent_http.py @@ -0,0 +1,104 @@ +# Copyright (c) 2025 Agentspan +# Licensed under the MIT License. See LICENSE file in the project root for details. + +"""Authenticated HTTP to Agentspan ``/agent/*`` endpoints via the SDK ``ApiClient``. + +Framework workers (claude_agent_sdk, langchain, langgraph) run in spawned worker +processes and receive ``(server_url, auth_key, auth_secret)`` as plain strings — a +live ``ApiClient`` can't cross the process boundary. This module reconstructs an +``ApiClient`` from those strings inside the worker, cached per +``(server_url, auth_key)`` so the token mint in the constructor happens once per +worker, and posts through :meth:`ApiClient.call_api`. That reuses the SDK's single +token authority (mint/cache/TTL-refresh/401-retry) instead of a parallel token +cache. +""" + +from __future__ import annotations + +import logging +import threading +from typing import Any, Dict, Optional, Tuple + +logger = logging.getLogger("conductor.ai.agents.agent_http") + +# The auth-setting name the generated clients use to drive X-Authorization +# injection (see OrkesAgentClient._AUTH_SETTINGS). +_AUTH_SETTINGS = ["api_key"] +_JSON_HEADERS = {"Accept": "application/json", "Content-Type": "application/json"} + +# One ApiClient per (server_url, auth_key). Building an ApiClient mints a token in +# its constructor, so caching is mandatory to avoid a /token mint on every call. +_API_CLIENTS: Dict[Tuple[str, str], Any] = {} +_API_CLIENTS_LOCK = threading.Lock() + + +def _agent_api_client(server_url: str, auth_key: str, auth_secret: str): + """Return a cached ``ApiClient`` for ``(server_url, auth_key)``. + + Imports are lazy so importing this module stays cheap and side-effect free + (spawn/pickle safety). + """ + key = (server_url, auth_key or "") + client = _API_CLIENTS.get(key) + if client is not None: + return client + + from conductor.client.configuration.configuration import Configuration + from conductor.client.configuration.settings.authentication_settings import ( + AuthenticationSettings, + ) + from conductor.client.http.api_client import ApiClient + + auth = ( + AuthenticationSettings(key_id=auth_key, key_secret=auth_secret or "") + if auth_key + else None + ) + config = Configuration(server_api_url=server_url, authentication_settings=auth) + with _API_CLIENTS_LOCK: + client = _API_CLIENTS.get(key) + if client is None: + client = ApiClient(config) + _API_CLIENTS[key] = client + return client + + +def agent_post( + server_url: str, + auth_key: str, + auth_secret: str, + path: str, + body: Optional[Dict[str, Any]] = None, + *, + read_response: bool = False, +) -> Optional[Any]: + """POST to an Agentspan ``/agent/*`` endpoint through the SDK ``ApiClient``. + + ``path`` is relative to the server host (which already ends in ``/api``), so it + must start with ``/`` and omit ``/api`` — e.g. ``/agent/events/{id}``. + + Returns the deserialized JSON body when ``read_response`` is True, otherwise + ``None``. Any transport/HTTP error (including a non-retryable ``ApiException``) + is swallowed and returns ``None`` so callers degrade exactly as the previous + raw-``requests`` paths did — a 401 is still auto-retried once inside + :meth:`ApiClient.call_api`. + """ + try: + client = _agent_api_client(server_url, auth_key, auth_secret) + return client.call_api( + path, + "POST", + {}, + [], + dict(_JSON_HEADERS), + body=body if body is not None else {}, + post_params=[], + files={}, + response_type="object" if read_response else None, + auth_settings=_AUTH_SETTINGS, + _return_http_data_only=True, + _preload_content=True, + ) + except Exception as exc: # ApiException + any transport-level error + logger.debug("agent_post failed (path=%s): %s", path, exc) + return None diff --git a/src/conductor/ai/agents/_internal/token_utils.py b/src/conductor/ai/agents/_internal/token_utils.py index 83f66b9f..18d614c1 100644 --- a/src/conductor/ai/agents/_internal/token_utils.py +++ b/src/conductor/ai/agents/_internal/token_utils.py @@ -1,24 +1,18 @@ # Copyright (c) 2025 Agentspan # Licensed under the MIT License. See LICENSE file in the project root for details. -"""Auth token helpers shared by the sync/async agent API clients and framework adapters. +"""JWT helper for the agent runtime. -Secured Conductor hosts (e.g. orkes) authenticate API calls with a JWT in the -``X-Authorization`` header, minted from an application access key via -``POST {server}/token``. These helpers centralize that mint (with an expiry-aware -process-wide cache) so every HTTP path — agent API, SSE streaming, framework event -pushes — sends the same correct header. Anonymous servers ignore the header. +Auth-token minting/caching lives in the SDK ``ApiClient`` (a single token +authority — mint/cache/TTL-refresh/401-retry); the worker HTTP path reuses it via +``conductor.ai.agents._internal.agent_http``. This module only *decodes* a JWT's +expiry. """ from __future__ import annotations import base64 import json -import logging -import threading -from typing import Dict, Optional, Tuple - -logger = logging.getLogger("conductor.ai.agents.token_utils") def decode_jwt_exp(token: str) -> float: @@ -36,65 +30,3 @@ def decode_jwt_exp(token: str) -> float: return float(payload.get("exp", 0) or 0) except Exception: return 0.0 - - -# Process-wide mint cache: (server_url, auth_key) -> (token, exp). Framework event -# pushes run on thread pools, so guard with a lock. -_TOKEN_CACHE: Dict[Tuple[str, str], Tuple[str, float]] = {} -_TOKEN_LOCK = threading.Lock() - - -def resolve_agent_api_token( - server_url: str, - api_key: Optional[str] = None, - auth_key: Optional[str] = None, - auth_secret: Optional[str] = None, -) -> Optional[str]: - """Resolve the JWT for agent API calls. - - An explicit ``api_key`` is already a token and returned as-is. Otherwise a JWT is - minted from ``auth_key``/``auth_secret`` via ``POST {server_url}/token`` and cached - until ~expiry. Returns None when no credentials are configured or the mint fails - (anonymous / security-disabled servers). - """ - if api_key: - return api_key - if not auth_key or not auth_secret: - return None - - import time - - cache_key = (server_url.rstrip("/"), auth_key) - with _TOKEN_LOCK: - cached = _TOKEN_CACHE.get(cache_key) - if cached: - token, exp = cached - if exp == 0.0 or time.time() < exp - 30: - return token - - import requests - - url = server_url.rstrip("/") + "/token" - try: - resp = requests.post(url, json={"keyId": auth_key, "keySecret": auth_secret}, timeout=30) - resp.raise_for_status() - token = resp.json().get("token") - except Exception as e: # pragma: no cover - network/credential failures - logger.warning("Failed to mint agent API token: %s", e) - return None - if not token: - return None - with _TOKEN_LOCK: - _TOKEN_CACHE[cache_key] = (token, decode_jwt_exp(token)) - return token - - -def agent_api_auth_headers( - server_url: str, - api_key: Optional[str] = None, - auth_key: Optional[str] = None, - auth_secret: Optional[str] = None, -) -> Dict[str, str]: - """``X-Authorization`` header dict for agent API calls ({} when anonymous).""" - token = resolve_agent_api_token(server_url, api_key, auth_key, auth_secret) - return {"X-Authorization": token} if token else {} diff --git a/src/conductor/ai/agents/cli_config.py b/src/conductor/ai/agents/cli_config.py index 29d54f5c..47b0147f 100644 --- a/src/conductor/ai/agents/cli_config.py +++ b/src/conductor/ai/agents/cli_config.py @@ -111,23 +111,26 @@ def _validate_cli_command(command: str, allowed_commands: List[str]) -> None: # ── Tool factory ─────────────────────────────────────────────────────── -def _make_cli_tool( - allowed_commands: List[str], - timeout: int = 30, - working_dir: Optional[str] = None, - allow_shell: bool = False, - agent_name: Optional[str] = None, -) -> Any: - """Create a ``@tool``-decorated ``run_command`` function. - - The returned function can be appended to ``Agent.tools`` directly. - """ - from conductor.ai.agents.tool import tool - - task_name = f"{agent_name}_run_command" if agent_name else "run_command" - - @tool(name=task_name) - def run_command( +class _CliCommandRunner: + """Run a CLI command.""" + + # A module-level callable class (not a closure) so the worker is spawn-safe: + # the registration probe pickles the tool by reference, and an instance's + # config (all plain data) pickles fine — a ```` closure would not. + def __init__( + self, + allowed_commands: List[str], + timeout: int = 30, + working_dir: Optional[str] = None, + allow_shell: bool = False, + ) -> None: + self.allowed_commands = list(allowed_commands or []) + self.timeout = timeout + self.working_dir = working_dir + self.allow_shell = allow_shell + + def __call__( + self, command: str, args: list = [], cwd: str = "", @@ -164,10 +167,10 @@ def run_command( executable = tokens[0] # Validate against whitelist (on the executable) - _validate_cli_command(executable, allowed_commands) + _validate_cli_command(executable, self.allowed_commands) # Shell gate - if shell and not allow_shell: + if shell and not self.allow_shell: raise ValueError("Shell mode is disabled for this agent. Do not set shell=True.") # Normalise args @@ -180,7 +183,7 @@ def run_command( argv = tokens[1:] + [str(a) for a in args] # Resolve working directory - effective_cwd = cwd if cwd else working_dir + effective_cwd = cwd if cwd else self.working_dir try: if shell: @@ -191,7 +194,7 @@ def run_command( shell=True, capture_output=True, text=True, - timeout=timeout, + timeout=self.timeout, cwd=effective_cwd or None, ) else: @@ -199,7 +202,7 @@ def run_command( [executable] + argv, capture_output=True, text=True, - timeout=timeout, + timeout=self.timeout, cwd=effective_cwd or None, ) @@ -224,12 +227,38 @@ def run_command( } except subprocess.TimeoutExpired: - raise TerminalToolError(f"Command timed out after {timeout}s") + raise TerminalToolError(f"Command timed out after {self.timeout}s") except FileNotFoundError: raise TerminalToolError(f"Command not found: {command}") except Exception as e: raise TerminalToolError(str(e)) + +def _make_cli_tool( + allowed_commands: List[str], + timeout: int = 30, + working_dir: Optional[str] = None, + allow_shell: bool = False, + agent_name: Optional[str] = None, +) -> Any: + """Create a ``@tool``-decorated ``run_command`` tool. + + The tool is backed by a module-level :class:`_CliCommandRunner` instance + (not a closure) so the registered worker is spawn-safe. The returned object + can be appended to ``Agent.tools`` directly. + """ + from conductor.ai.agents.tool import tool + + task_name = f"{agent_name}_run_command" if agent_name else "run_command" + + runner = _CliCommandRunner( + allowed_commands=allowed_commands, + timeout=timeout, + working_dir=working_dir, + allow_shell=allow_shell, + ) + run_command = tool(name=task_name)(runner) + # Build dynamic description desc = f"Run a CLI command directly. Timeout: {timeout}s." if allowed_commands: diff --git a/src/conductor/ai/agents/ext.py b/src/conductor/ai/agents/ext.py index d3d3815e..b479519f 100644 --- a/src/conductor/ai/agents/ext.py +++ b/src/conductor/ai/agents/ext.py @@ -16,6 +16,91 @@ logger = logging.getLogger("conductor.ai.agents.ext") +# ── OpenAI Assistants API call ───────────────────────────────────────── + + +class _AssistantCall: + """Spawn-safe callable that runs a message against the OpenAI Assistants API. + + A module-level class (not a closure over the Agent) so the registered + worker pickles by value — every attribute is plain data. This is what makes + the auto-generated ``*_assistant_call`` tool safe under the ``spawn`` start + method (a ```` closure over ``self`` would not be importable). + """ + + def __init__( + self, + assistant_id: Optional[str] = None, + api_key: Optional[str] = None, + openai_tools: Optional[List[Dict[str, Any]]] = None, + model: str = "openai/gpt-4o", + instructions: str = "", + ) -> None: + self.assistant_id = assistant_id + self.api_key = api_key + self.openai_tools = list(openai_tools or []) + self.model = model + self.instructions = instructions + + def __call__(self, message: str) -> str: + """Send a message to the OpenAI Assistant and get a response.""" + try: + import openai + except ImportError: + return "Error: openai package not installed. Install with: pip install openai" + + import os + + api_key = self.api_key or os.environ.get("OPENAI_API_KEY") + if not api_key: + return "Error: No OpenAI API key provided." + + client = openai.OpenAI(api_key=api_key) + + # Create assistant if needed + assistant_id = self.assistant_id + if not assistant_id: + model_name = self.model.split("/", 1)[-1] + assistant = client.beta.assistants.create( + model=model_name, + instructions=self.instructions, + tools=self.openai_tools, + ) + assistant_id = assistant.id + self.assistant_id = assistant_id + + try: + # Create thread and run + thread = client.beta.threads.create() + client.beta.threads.messages.create( + thread_id=thread.id, + role="user", + content=message, + ) + + run = client.beta.threads.runs.create_and_poll( + thread_id=thread.id, + assistant_id=assistant_id, + ) + + if run.status == "completed": + messages = client.beta.threads.messages.list(thread_id=thread.id) + for msg in messages.data: + if msg.role == "assistant": + parts = [] + for block in msg.content: + if hasattr(block, "text"): + parts.append(block.text.value) + if parts: + return "\n".join(parts) + return "No response from assistant." + else: + return f"Assistant run ended with status: {run.status}" + + except Exception as e: + return f"OpenAI Assistant error: {e}" + + # ── GPTAssistantAgent ────────────────────────────────────────────────── @@ -79,23 +164,26 @@ def __init__( if assistant_id: metadata["_assistant_id"] = assistant_id - # Build the tool that calls the Assistants API - from conductor.ai.agents.tool import tool + # Normalise the model string + if "/" not in model: + model = f"openai/{model}" - agent_ref = self + # Build the tool that calls the Assistants API. Backed by a module-level + # callable (not a closure) so the registered worker is spawn-safe. + from conductor.ai.agents.tool import tool - @tool(name=f"{name}_assistant_call") - def call_assistant(message: str) -> str: - """Send a message to the OpenAI Assistant and get a response.""" - return agent_ref._run_assistant(message) + runner = _AssistantCall( + assistant_id=assistant_id, + api_key=api_key, + openai_tools=self.openai_tools, + model=model, + instructions=instructions or "You are a helpful assistant.", + ) + call_assistant = tool(name=f"{name}_assistant_call")(runner) tools = kwargs.pop("tools", []) or [] tools.append(call_assistant) - # Normalise the model string - if "/" not in model: - model = f"openai/{model}" - super().__init__( name=name, model=model, @@ -107,63 +195,22 @@ def call_assistant(message: str) -> str: ) def _run_assistant(self, message: str) -> str: - """Execute a message against the OpenAI Assistants API.""" - try: - import openai - except ImportError: - return "Error: openai package not installed. Install with: pip install openai" - - import os - - api_key = self._api_key or os.environ.get("OPENAI_API_KEY") - if not api_key: - return "Error: No OpenAI API key provided." - - client = openai.OpenAI(api_key=api_key) - - # Create assistant if needed - assistant_id = self.assistant_id - if not assistant_id: - instructions = self.instructions() if callable(self.instructions) else self.instructions - model_name = self.model.split("/", 1)[-1] - assistant = client.beta.assistants.create( - model=model_name, - instructions=instructions, - tools=self.openai_tools, - ) - assistant_id = assistant.id - self.assistant_id = assistant_id - - try: - # Create thread and run - thread = client.beta.threads.create() - client.beta.threads.messages.create( - thread_id=thread.id, - role="user", - content=message, - ) - - run = client.beta.threads.runs.create_and_poll( - thread_id=thread.id, - assistant_id=assistant_id, - ) - - if run.status == "completed": - messages = client.beta.threads.messages.list(thread_id=thread.id) - for msg in messages.data: - if msg.role == "assistant": - parts = [] - for block in msg.content: - if hasattr(block, "text"): - parts.append(block.text.value) - if parts: - return "\n".join(parts) - return "No response from assistant." - else: - return f"Assistant run ended with status: {run.status}" - - except Exception as e: - return f"OpenAI Assistant error: {e}" + """Execute a message against the OpenAI Assistants API. + + Delegates to :class:`_AssistantCall` (the same logic the worker runs), + propagating a lazily-created assistant id back onto the agent. + """ + instructions = self.instructions() if callable(self.instructions) else self.instructions + runner = _AssistantCall( + assistant_id=self.assistant_id, + api_key=self._api_key, + openai_tools=self.openai_tools, + model=self.model, + instructions=instructions, + ) + result = runner(message) + self.assistant_id = runner.assistant_id + return result def __repr__(self) -> str: return f"GPTAssistantAgent(name={self.name!r}, assistant_id={self.assistant_id!r})" diff --git a/src/conductor/ai/agents/frameworks/claude_agent_sdk.py b/src/conductor/ai/agents/frameworks/claude_agent_sdk.py index af8e8fd2..6ae96918 100644 --- a/src/conductor/ai/agents/frameworks/claude_agent_sdk.py +++ b/src/conductor/ai/agents/frameworks/claude_agent_sdk.py @@ -20,7 +20,7 @@ from dataclasses import is_dataclass, replace from typing import Any, Dict, List, Optional, Tuple -from conductor.ai.agents._internal.token_utils import agent_api_auth_headers +from conductor.ai.agents._internal.agent_http import _agent_api_client, agent_post from conductor.ai.agents.frameworks.serializer import WorkerInfo logger = logging.getLogger("conductor.ai.agents.frameworks.claude_agent_sdk") @@ -888,17 +888,24 @@ def _push_event_nonblocking( def _do_push(): try: - import requests - - url = f"{server_url}/agent/events/{execution_id}" - headers = agent_api_auth_headers(server_url, auth_key=auth_key, auth_secret=auth_secret) - requests.post(url, json=event, headers=headers, timeout=5) + agent_post(server_url, auth_key, auth_secret, f"/agent/events/{execution_id}", event) except Exception as exc: logger.debug("Event push failed (execution_id=%s): %s", execution_id, exc) _EVENT_PUSH_POOL.submit(_do_push) +def _task_client(server_url: str, auth_key: str, auth_secret: str): + """Return a ``TaskResourceApi`` backed by the shared cached ``ApiClient``. + + Reuses the single per-(server_url, auth_key) ApiClient from ``agent_http`` so + the ``/tasks`` progress update and the ``/agent/*`` posts share one token. + """ + from conductor.client.http.api.task_resource_api import TaskResourceApi + + return TaskResourceApi(_agent_api_client(server_url, auth_key, auth_secret)) + + def _update_task_progress_nonblocking( task_id: str, execution_id: str, @@ -924,20 +931,15 @@ def _update_task_progress_nonblocking( def _do_update(): try: - import requests + from conductor.client.http.models.task_result import TaskResult - url = f"{server_url}/tasks" - headers: Dict[str, str] = {"Content-Type": "application/json"} - headers.update( - agent_api_auth_headers(server_url, auth_key=auth_key, auth_secret=auth_secret) + result = TaskResult( + task_id=task_id, + workflow_instance_id=execution_id, + status="IN_PROGRESS", + output_data=progress_data, ) - body = { - "taskId": task_id, - "workflowInstanceId": execution_id, - "status": "IN_PROGRESS", - "outputData": progress_data, - } - requests.post(url, json=body, headers=headers, timeout=5) + _task_client(server_url, auth_key, auth_secret).update_task(result) except Exception as exc: logger.debug( "Task progress update failed (task_id=%s, execution_id=%s): %s", @@ -968,26 +970,15 @@ def _create_tracking_workflow( Returns the execution ID of the new workflow, or None on failure. Uses POST /api/agent/execution (Agentspan custom endpoint). """ - try: - import requests - - url = f"{server_url}/agent/execution" - headers: Dict[str, str] = {"Content-Type": "application/json"} - headers.update( - agent_api_auth_headers(server_url, auth_key=auth_key, auth_secret=auth_secret) - ) - body: Dict[str, Any] = {"workflowName": workflow_name, "input": input_data} - if parent_workflow_id: - body["parentWorkflowId"] = parent_workflow_id - if parent_workflow_task_id: - body["parentWorkflowTaskId"] = parent_workflow_task_id - resp = requests.post(url, json=body, headers=headers, timeout=5) - if resp.status_code < 400: - return resp.json().get("executionId") - return None - except Exception as exc: - logger.debug("Create tracking workflow failed: %s", exc) - return None + body: Dict[str, Any] = {"workflowName": workflow_name, "input": input_data} + if parent_workflow_id: + body["parentWorkflowId"] = parent_workflow_id + if parent_workflow_task_id: + body["parentWorkflowTaskId"] = parent_workflow_task_id + resp = agent_post( + server_url, auth_key, auth_secret, "/agent/execution", body, read_response=True + ) + return resp.get("executionId") if isinstance(resp, dict) else None def _inject_tool_task( @@ -1009,32 +1000,23 @@ def _inject_tool_task( For SUB_WORKFLOW tasks, pass sub_workflow_param with keys: name, version, executionId (the tracking sub-workflow). """ - try: - import requests - - url = f"{server_url}/agent/{execution_id}/tasks" - headers: Dict[str, str] = {"Content-Type": "application/json"} - headers.update( - agent_api_auth_headers(server_url, auth_key=auth_key, auth_secret=auth_secret) - ) - body: Dict[str, Any] = { - "taskDefName": tool_name, - "referenceTaskName": ref_name, - "type": task_type, - "inputData": input_data, - } - if sub_workflow_param: - body["subWorkflowParam"] = sub_workflow_param - resp = requests.post(url, json=body, headers=headers, timeout=5) - return resp.status_code < 400 - except Exception as exc: - logger.debug( - "Inject tool task failed (execution_id=%s, ref=%s): %s", - execution_id, - ref_name, - exc, - ) - return False + body: Dict[str, Any] = { + "taskDefName": tool_name, + "referenceTaskName": ref_name, + "type": task_type, + "inputData": input_data, + } + if sub_workflow_param: + body["subWorkflowParam"] = sub_workflow_param + resp = agent_post( + server_url, + auth_key, + auth_secret, + f"/agent/{execution_id}/tasks", + body, + read_response=True, + ) + return resp is not None def _complete_tool_task_nonblocking( @@ -1053,14 +1035,13 @@ def _complete_tool_task_nonblocking( def _do_complete(): try: - import requests - - url = f"{server_url}/agent/tasks/{execution_id}/{ref_name}/{status}" - headers: Dict[str, str] = {"Content-Type": "application/json"} - headers.update( - agent_api_auth_headers(server_url, auth_key=auth_key, auth_secret=auth_secret) + agent_post( + server_url, + auth_key, + auth_secret, + f"/agent/tasks/{execution_id}/{ref_name}/{status}", + output_data, ) - requests.post(url, json=output_data, headers=headers, timeout=5) except Exception as exc: logger.debug( "Complete tool task failed (execution_id=%s, ref=%s): %s", @@ -1086,14 +1067,13 @@ def _complete_workflow_nonblocking( def _do_complete(): try: - import requests - - url = f"{server_url}/agent/execution/{workflow_execution_id}/complete" - headers: Dict[str, str] = {"Content-Type": "application/json"} - headers.update( - agent_api_auth_headers(server_url, auth_key=auth_key, auth_secret=auth_secret) + agent_post( + server_url, + auth_key, + auth_secret, + f"/agent/execution/{workflow_execution_id}/complete", + output_data or {}, ) - requests.post(url, json=output_data or {}, headers=headers, timeout=5) except Exception as exc: logger.debug( "Complete workflow failed (execution_id=%s): %s", @@ -1122,8 +1102,7 @@ def _resolve_credentials( shared process-wide lock. See ``docs/design/secret-injection-contract.md``. """ from conductor.ai.agents.runtime._dispatch import ( - _extract_execution_token, - _get_credential_fetcher, + _resolve_secrets_from_task, _workflow_credentials, _workflow_credentials_lock, ) @@ -1135,13 +1114,5 @@ def _resolve_credentials( cred_names = list(_workflow_credentials.get(exec_id, [])) if not cred_names: return {} - token = _extract_execution_token(task) - if not token: - logger.warning( - "No execution token in task for Claude Agent SDK worker — " - "credentials %s will not be injected", - cred_names, - ) - return {} - fetcher = _get_credential_fetcher() - return fetcher.fetch(token, cred_names) + # Values the host resolved and delivered on Task.runtimeMetadata. + return _resolve_secrets_from_task(task, cred_names) diff --git a/src/conductor/ai/agents/frameworks/langchain.py b/src/conductor/ai/agents/frameworks/langchain.py index 26939d38..d4fba09f 100644 --- a/src/conductor/ai/agents/frameworks/langchain.py +++ b/src/conductor/ai/agents/frameworks/langchain.py @@ -10,7 +10,7 @@ from concurrent.futures import ThreadPoolExecutor from typing import Any, Dict, List, Optional, Tuple -from conductor.ai.agents._internal.token_utils import agent_api_auth_headers +from conductor.ai.agents._internal.agent_http import agent_post from conductor.ai.agents.frameworks.serializer import WorkerInfo logger = logging.getLogger("conductor.ai.agents.frameworks.langchain") @@ -113,8 +113,7 @@ def tool_worker(task: Task) -> TaskResult: resolved_secrets = {} try: from conductor.ai.agents.runtime._dispatch import ( - _extract_execution_token, - _get_credential_fetcher, + _resolve_secrets_from_task, _workflow_credentials, _workflow_credentials_lock, ) @@ -125,16 +124,8 @@ def tool_worker(task: Task) -> TaskResult: with _workflow_credentials_lock: cred_names = list(_workflow_credentials.get(exec_id, [])) if cred_names: - token = _extract_execution_token(task) - if token: - fetcher = _get_credential_fetcher() - resolved_secrets = fetcher.fetch(token, cred_names) - else: - logger.warning( - "No execution token in task for LangChain worker — " - "credentials %s will not be injected", - cred_names, - ) + # Values the host resolved and delivered on Task.runtimeMetadata. + resolved_secrets = _resolve_secrets_from_task(task, cred_names) except Exception as _cred_err: logger.warning("Failed to resolve credentials for LangChain: %s", _cred_err) @@ -254,11 +245,7 @@ def _push_event_nonblocking( def _do_push(): try: - import requests - - url = f"{server_url}/agent/events/{execution_id}" - headers = agent_api_auth_headers(server_url, auth_key=auth_key, auth_secret=auth_secret) - requests.post(url, json=event, headers=headers, timeout=5) + agent_post(server_url, auth_key, auth_secret, f"/agent/events/{execution_id}", event) except Exception as exc: logger.debug("Event push failed (execution_id=%s): %s", execution_id, exc) diff --git a/src/conductor/ai/agents/frameworks/langgraph.py b/src/conductor/ai/agents/frameworks/langgraph.py index e9e44baa..872152c5 100644 --- a/src/conductor/ai/agents/frameworks/langgraph.py +++ b/src/conductor/ai/agents/frameworks/langgraph.py @@ -25,7 +25,7 @@ from concurrent.futures import ThreadPoolExecutor from typing import Any, Dict, List, Optional, Tuple -from conductor.ai.agents._internal.token_utils import agent_api_auth_headers +from conductor.ai.agents._internal.agent_http import agent_post from conductor.ai.agents.frameworks.serializer import WorkerInfo logger = logging.getLogger("conductor.ai.agents.frameworks.langgraph") @@ -1531,8 +1531,7 @@ def tool_worker(task: Task) -> TaskResult: resolved_secrets = {} try: from conductor.ai.agents.runtime._dispatch import ( - _extract_execution_token, - _get_credential_fetcher, + _resolve_secrets_from_task, _workflow_credentials, _workflow_credentials_lock, ) @@ -1543,16 +1542,8 @@ def tool_worker(task: Task) -> TaskResult: with _workflow_credentials_lock: cred_names = list(_workflow_credentials.get(exec_id, [])) if cred_names: - token = _extract_execution_token(task) - if token: - fetcher = _get_credential_fetcher() - resolved_secrets = fetcher.fetch(token, cred_names) - else: - logger.warning( - "No execution token in task for LangGraph worker — " - "credentials %s will not be injected", - cred_names, - ) + # Values the host resolved and delivered on Task.runtimeMetadata. + resolved_secrets = _resolve_secrets_from_task(task, cred_names) except Exception as _cred_err: logger.warning("Failed to resolve credentials for LangGraph: %s", _cred_err) @@ -1778,11 +1769,7 @@ def _push_event_nonblocking( def _do_push(): try: - import requests - - url = f"{server_url}/agent/events/{execution_id}" - headers = agent_api_auth_headers(server_url, auth_key=auth_key, auth_secret=auth_secret) - requests.post(url, json=event, headers=headers, timeout=5) + agent_post(server_url, auth_key, auth_secret, f"/agent/events/{execution_id}", event) except Exception as exc: logger.debug("Event push failed (execution_id=%s): %s", execution_id, exc) diff --git a/src/conductor/ai/agents/run.py b/src/conductor/ai/agents/run.py index 83bc41bc..8763f9c3 100644 --- a/src/conductor/ai/agents/run.py +++ b/src/conductor/ai/agents/run.py @@ -10,10 +10,11 @@ For production use, prefer creating an :class:`AgentRuntime` explicitly:: from conductor.ai.agents import Agent, AgentRuntime + from conductor.client.configuration.configuration import Configuration agent = Agent(name="hello", model="openai/gpt-4o") - with AgentRuntime(server_url="https://play.orkes.io/api") as runtime: + with AgentRuntime(Configuration(server_api_url="https://play.orkes.io/api")) as runtime: result = runtime.run(agent, "Hello!") print(result.output) @@ -27,7 +28,10 @@ import atexit import logging import threading -from typing import Any, List, Optional +from typing import TYPE_CHECKING, Any, List, Optional + +if TYPE_CHECKING: + from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.agent import Agent from conductor.ai.agents.result import ( @@ -42,12 +46,13 @@ # ── Singleton runtime ──────────────────────────────────────────────────── -_default_config: Optional[Any] = None +_default_config: Optional[AgentConfig] = None +_default_configuration = None # conductor Configuration (connection/auth/log level) _default_runtime = None _runtime_lock = threading.Lock() -def configure(config=None, **kwargs): +def configure(config: Optional[AgentConfig] = None, *, configuration=None, **kwargs): """Pre-configure the default singleton runtime. Must be called **before** the first :func:`run`, :func:`start`, or @@ -55,12 +60,13 @@ def configure(config=None, **kwargs): :func:`shutdown` / recreate cycles. Args: - config: An :class:`AgentConfig` instance. If provided, *kwargs* - are ignored. - **kwargs: Individual config fields to override on top of - :meth:`AgentConfig.from_env` defaults (e.g. - ``server_url="https://prod:8080/api"``, - ``auto_start_server=False``). + config: An :class:`AgentConfig` (agent-runtime settings). If provided, + *kwargs* are ignored. + configuration: A conductor :class:`Configuration` carrying the server + connection, auth, and log level. Defaults to the env-based + ``Configuration()``. + **kwargs: Individual :class:`AgentConfig` field overrides on top of + :meth:`AgentConfig.from_env` defaults (e.g. ``worker_thread_count=4``). Raises: RuntimeError: If the singleton runtime already exists. Call @@ -70,19 +76,22 @@ def configure(config=None, **kwargs): Example:: import conductor.ai.agents as ag + from conductor.client.configuration.configuration import Configuration - ag.configure(server_url="https://prod:8080/api", auto_start_server=False) + ag.configure(configuration=Configuration(server_api_url="https://prod:8080/api")) result = ag.run(agent, "Hello!") """ - global _default_config, _default_runtime + global _default_config, _default_configuration, _default_runtime if _default_runtime is not None: raise RuntimeError( "configure() must be called before the first run/start/stream call. " "Call shutdown() first to reset the default runtime." ) + if configuration is not None: + _default_configuration = configuration if config is not None: _default_config = config - else: + elif kwargs: from conductor.ai.agents.runtime.config import AgentConfig base = AgentConfig.from_env() @@ -101,7 +110,9 @@ def _get_default_runtime(): if _default_runtime is None: from conductor.ai.agents.runtime.runtime import AgentRuntime - _default_runtime = AgentRuntime(config=_default_config) + _default_runtime = AgentRuntime( + _default_configuration, settings=_default_config + ) logger.info("Created default AgentRuntime singleton") return _default_runtime @@ -250,6 +261,7 @@ def run( idempotency_key: Optional[str] = None, on_event: Optional[Any] = None, credentials: Optional[List[str]] = None, + run_settings: Optional[Any] = None, runtime: Optional[Any] = None, **kwargs: Any, ) -> AgentResult: @@ -293,6 +305,7 @@ def run( idempotency_key=idempotency_key, on_event=on_event, credentials=credentials, + run_settings=run_settings, **kwargs, ) @@ -304,6 +317,7 @@ def start( media: Optional[List[str]] = None, session_id: Optional[str] = None, idempotency_key: Optional[str] = None, + run_settings: Optional[Any] = None, runtime: Optional[Any] = None, **kwargs: Any, ) -> AgentHandle: @@ -340,7 +354,13 @@ def start( """ rt = runtime or _get_default_runtime() return rt.start( - agent, prompt, media=media, session_id=session_id, idempotency_key=idempotency_key, **kwargs + agent, + prompt, + media=media, + session_id=session_id, + idempotency_key=idempotency_key, + run_settings=run_settings, + **kwargs, ) @@ -401,6 +421,7 @@ async def run_async( idempotency_key: Optional[str] = None, on_event: Optional[Any] = None, credentials: Optional[List[str]] = None, + run_settings: Optional[Any] = None, runtime: Optional[Any] = None, **kwargs: Any, ) -> AgentResult: @@ -439,6 +460,7 @@ async def run_async( idempotency_key=idempotency_key, on_event=on_event, credentials=credentials, + run_settings=run_settings, **kwargs, ) @@ -450,6 +472,7 @@ async def start_async( media: Optional[List[str]] = None, session_id: Optional[str] = None, idempotency_key: Optional[str] = None, + run_settings: Optional[Any] = None, runtime: Optional[Any] = None, **kwargs: Any, ) -> AgentHandle: @@ -479,7 +502,13 @@ async def start_async( """ rt = runtime or _get_default_runtime() return await rt.start_async( - agent, prompt, media=media, session_id=session_id, idempotency_key=idempotency_key, **kwargs + agent, + prompt, + media=media, + session_id=session_id, + idempotency_key=idempotency_key, + run_settings=run_settings, + **kwargs, ) diff --git a/src/conductor/ai/agents/run_settings.py b/src/conductor/ai/agents/run_settings.py new file mode 100644 index 00000000..5347135e --- /dev/null +++ b/src/conductor/ai/agents/run_settings.py @@ -0,0 +1,78 @@ +# Copyright (c) 2025 Agentspan +# Licensed under the MIT License. See LICENSE file in the project root for details. + +"""Per-run LLM overrides for :meth:`AgentRuntime.run` / :meth:`AgentRuntime.start`. + +``RunSettings`` lets a single invocation override the LLM settings baked into an +:class:`Agent` (model, temperature, …) without constructing a new agent. Only the +fields you set override; unset fields (``None``) keep the agent's own values. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Optional + + +@dataclass +class RunSettings: + """Per-invocation LLM overrides applied on top of an :class:`Agent`'s settings. + + Pass via the ``run_settings=`` keyword of ``run``/``start`` (and their async + variants). Only non-``None`` fields override the agent; everything else is + left as the agent defined it. + + Attributes: + model: Provider/model id (e.g. ``"openai/gpt-4o"``). + temperature: Sampling temperature. + max_tokens: Maximum tokens for the completion. + reasoning_effort: Reasoning effort for reasoning models (e.g. ``"high"``). + thinking_budget_tokens: Extended-thinking token budget (enables thinking). + """ + + model: Optional[str] = None + temperature: Optional[float] = None + max_tokens: Optional[int] = None + reasoning_effort: Optional[str] = None + thinking_budget_tokens: Optional[int] = None + + def to_config_overrides(self) -> Dict[str, Any]: + """Map the set fields to ``agentConfig`` wire keys. + + Mirrors :class:`~conductor.ai.agents.config_serializer.AgentConfigSerializer` + so the wire-key names live in one place. Uses ``is not None`` so + ``temperature=0.0`` and ``max_tokens=0`` are honored (they are falsy). + """ + overrides: Dict[str, Any] = {} + if self.model is not None: + overrides["model"] = self.model + if self.temperature is not None: + overrides["temperature"] = self.temperature + if self.max_tokens is not None: + overrides["maxTokens"] = self.max_tokens + if self.reasoning_effort is not None: + overrides["reasoningEffort"] = self.reasoning_effort + if self.thinking_budget_tokens is not None: + overrides["thinkingConfig"] = { + "enabled": True, + "budgetTokens": self.thinking_budget_tokens, + } + return overrides + + @classmethod + def coerce(cls, obj: Any) -> Optional["RunSettings"]: + """Normalise ``None`` / a :class:`RunSettings` / a plain dict to RunSettings. + + A dict is expanded as keyword args, so unknown keys raise ``TypeError`` + (the intended minimal validation). Value validation (model names, + ranges) is left to the server. + """ + if obj is None: + return None + if isinstance(obj, RunSettings): + return obj + if isinstance(obj, dict): + return cls(**obj) + raise TypeError( + f"run_settings must be a RunSettings or dict, got {type(obj).__name__}" + ) diff --git a/src/conductor/ai/agents/runtime/_dispatch.py b/src/conductor/ai/agents/runtime/_dispatch.py index fb50c17a..988c77e6 100644 --- a/src/conductor/ai/agents/runtime/_dispatch.py +++ b/src/conductor/ai/agents/runtime/_dispatch.py @@ -156,49 +156,33 @@ def _coerce_value(value, annotation): return value -# Lazily created credential fetcher — initialized from AgentConfig on first use -_credential_fetcher = None +def _resolve_secrets_from_task(task, names: list) -> dict: + """Resolve declared secret *names* from the values the host delivered on the task. + Secured hosts (e.g. orkes/agentspan) resolve a worker's declared + ``TaskDef.runtimeMetadata`` secret names at poll time and deliver the values on + the wire-only ``Task.runtimeMetadata`` map (conductor-oss PR #1255) — never + persisted to task input. The worker just reads them here; there is no separate + fetch call or execution token. -def _get_credential_fetcher(): - """Return the module-level WorkerCredentialFetcher, creating it on first call. - - The fetcher is initialized from AgentConfig.from_env() so it picks up - AGENTSPAN_SERVER_URL, AGENTSPAN_API_KEY, AGENTSPAN_SECRET_STRICT_MODE. + Raises :class:`CredentialNotFoundError` for any declared name the host did not + deliver (secret not stored, or the TaskDef didn't declare it). """ - global _credential_fetcher - if _credential_fetcher is None: - from conductor.ai.agents.runtime.config import AgentConfig - from conductor.ai.agents.runtime.credentials.fetcher import WorkerCredentialFetcher - - config = AgentConfig.from_env() - _credential_fetcher = WorkerCredentialFetcher( - server_url=config.server_url, - strict_mode=config.secret_strict_mode, - api_key=config.api_key or config.auth_key, + from conductor.ai.agents.runtime.credentials.types import CredentialNotFoundError + + if not names: + return {} + delivered = getattr(task, "runtime_metadata", None) or {} + resolved = {n: delivered[n] for n in names if n in delivered} + missing = [n for n in names if n not in delivered] + if missing: + raise CredentialNotFoundError( + missing, + "Not delivered by the server on this task. Ensure each secret is stored " + "(agentspan credentials set --name ) and declared on the tool/agent " + "so the server resolves it at poll time.", ) - return _credential_fetcher - - -def _extract_execution_token(task) -> str | None: - """Extract __agentspan_ctx__.execution_token from a Conductor task. - - Checks task.input_data first (most common), then task.workflow_input. - Returns None if not present. - """ - # input_data is the primary source (set by Conductor enrichment scripts) - ctx = (task.input_data or {}).get("__agentspan_ctx__") - if isinstance(ctx, dict): - return ctx.get("execution_token") or None - if isinstance(ctx, str) and ctx: - return ctx - # Fallback: check workflow_input (set at workflow start) - ctx = (getattr(task, "workflow_input", None) or {}).get("__agentspan_ctx__") - if isinstance(ctx, dict): - return ctx.get("execution_token") or None - if isinstance(ctx, str) and ctx: - return ctx - return None + return resolved def _get_credential_names_from_tool(tool_func) -> list: @@ -470,10 +454,8 @@ def tool_worker(task: Task) -> TaskResult: ) resolved_secrets = {} if credential_names: - token = _extract_execution_token(task) - fetcher = _get_credential_fetcher() try: - resolved_secrets = fetcher.fetch(token, credential_names) + resolved_secrets = _resolve_secrets_from_task(task, credential_names) except Exception as cred_err: # Credential errors are configuration issues — non-retryable. logger.error( diff --git a/src/conductor/ai/agents/runtime/_worker_entries.py b/src/conductor/ai/agents/runtime/_worker_entries.py index 71b5cba9..9373f13f 100644 --- a/src/conductor/ai/agents/runtime/_worker_entries.py +++ b/src/conductor/ai/agents/runtime/_worker_entries.py @@ -492,22 +492,62 @@ async def __call__(self, result: str = "", iteration: int = 0) -> object: class CheckTransferEntry: - """Detect transfer tool calls (was ``check_transfer_worker``; stateless).""" + """Detect transfer tool calls (was ``check_transfer_worker``; stateless). + + Selection is first-wins: when the LLM emits multiple transfer calls in one + turn only the first is honored (the swarm loop can only hand off to one + agent). The others are surfaced as ``dropped_transfers`` so the intent is + visible in the task output instead of silently discarded — the transfer + tool descriptions instruct the model to call at most one per turn. + + ``transfer_message`` carries the transfer tool's ``message`` argument (the + hand-off note for the receiving agent); the server compiler records it in + the conversation as ``[agent -> target]: ``. + """ async def __call__(self, tool_calls: object = None, _unused: str = "") -> object: + transfers = [] for tc in tool_calls or []: - name = tc.get("name", "") - if "_transfer_to_" in name: - return {"is_transfer": True, "transfer_to": name.split("_transfer_to_", 1)[1]} - return {"is_transfer": False, "transfer_to": ""} + name = tc.get("name", "") or "" + if "_transfer_to_" not in name: + continue + params = tc.get("inputParameters") or {} + message = params.get("message") + transfers.append( + { + "transfer_to": name.split("_transfer_to_", 1)[1], + "message": "" if message is None else str(message), + } + ) + if not transfers: + return {"is_transfer": False, "transfer_to": "", "transfer_message": ""} + first = transfers[0] + out = { + "is_transfer": True, + "transfer_to": first["transfer_to"], + "transfer_message": first["message"], + } + if len(transfers) > 1: + import logging + + logging.getLogger(__name__).warning( + "Multiple transfer calls in one turn; honoring '%s', dropping %s", + first["transfer_to"], + [t["transfer_to"] for t in transfers[1:]], + ) + out["dropped_transfers"] = transfers[1:] + return out class TransferNoopEntry: """No-op transfer tool (was the nested ``transfer_worker``; handoff is - detected by check_transfer from toolCalls output).""" + detected by check_transfer from toolCalls output). Echoes the hand-off + ``message`` so it is visible in the task output / UI.""" - async def __call__(self) -> object: - return {} + async def __call__(self, message: str = "") -> object: + if not message: + return {} + return {"message": message} class TransferUnreachableEntry: diff --git a/src/conductor/ai/agents/runtime/config.py b/src/conductor/ai/agents/runtime/config.py index 2d2b490a..9a52b00b 100644 --- a/src/conductor/ai/agents/runtime/config.py +++ b/src/conductor/ai/agents/runtime/config.py @@ -8,8 +8,8 @@ Usage:: - config = AgentConfig.from_env() # load from env - config = AgentConfig(server_url="http://custom:8080/api") # explicit + config = AgentConfig.from_env() # load from env + config = AgentConfig(worker_thread_count=4) # explicit override """ from __future__ import annotations @@ -44,104 +44,59 @@ def _env_int(var: str, default: int = 0) -> int: return int(val) +def _env_float(var: str, default: float = 0.0) -> float: + """Read a float environment variable.""" + val = os.environ.get(var) + if val is None or val.strip() == "": + return default + return float(val) + + @dataclass class AgentConfig: - """Configuration for the agents runtime. + """Agent-runtime settings. + + Server connection and auth (URL, credentials) and the SDK-wide log level + live on the conductor :class:`Configuration`, not here — this holds only + agent-runtime concerns (worker pool + liveness monitoring). Attributes: - server_url: Agentspan server API URL. - api_key: Bearer token or static API key for the Authorization header. - Preferred over auth_key/auth_secret for new deployments. - auth_key: Auth key (kept for backward compatibility). - auth_secret: Auth secret (kept for backward compatibility). worker_poll_interval_ms: Worker polling interval in milliseconds. worker_thread_count: Number of threads per worker. auto_start_workers: Whether to auto-start worker processes. daemon_workers: Whether worker processes are daemon (killed on exit). - auto_start_server: Whether to auto-start the local server process. auto_register_integrations: Auto-create LLM integrations on startup. - secret_strict_mode: When ``True``, disables env var fallback for - credential resolution. Required credentials must come from the - credential service. - log_level: Logging level for the agentspan logger. + streaming_enabled: Whether ``stream()`` uses server-sent events. + liveness_enabled: Start a server-liveness monitor for stateful runs so + ``result()`` / ``join()`` don't block forever if the worker dies. + liveness_stall_seconds: Idle window (no polls) before a run is + considered stalled. + liveness_check_interval_seconds: How often the monitor polls. """ - server_url: str = "http://localhost:8080/api" - api_key: Optional[str] = None - auth_key: Optional[str] = None - auth_secret: Optional[str] = None - llm_retry_count: int = 3 worker_poll_interval_ms: int = 100 worker_thread_count: int = 1 auto_start_workers: bool = True - auto_start_server: bool = True daemon_workers: bool = True auto_register_integrations: bool = False streaming_enabled: bool = True - secret_strict_mode: bool = False - log_level: str = "INFO" - - def __post_init__(self): - """Normalise server_url: auto-append /api if missing.""" - if self.server_url: - stripped = self.server_url.rstrip("/") - if not stripped.endswith("/api"): - logger.info( - "server_url %r does not end with '/api' — appending automatically.", - self.server_url, - ) - self.server_url = stripped + "/api" - else: - self.server_url = stripped + liveness_enabled: bool = True + liveness_stall_seconds: float = 30.0 + liveness_check_interval_seconds: float = 10.0 @classmethod def from_env(cls) -> AgentConfig: """Create an ``AgentConfig`` by reading ``AGENTSPAN_*`` env vars.""" - log_level = _env("AGENTSPAN_LOG_LEVEL", "INFO") - if isinstance(log_level, str) and log_level.strip() == "": - log_level = "INFO" return cls( - server_url=_env("AGENTSPAN_SERVER_URL", "http://localhost:8080/api"), - api_key=_env("AGENTSPAN_API_KEY"), - auth_key=_env("AGENTSPAN_AUTH_KEY"), - auth_secret=_env("AGENTSPAN_AUTH_SECRET"), - llm_retry_count=_env_int("AGENTSPAN_LLM_RETRY_COUNT", 3), worker_poll_interval_ms=_env_int("AGENTSPAN_WORKER_POLL_INTERVAL", 100), worker_thread_count=_env_int("AGENTSPAN_WORKER_THREADS", 1), auto_start_workers=_env_bool("AGENTSPAN_AUTO_START_WORKERS", True), - auto_start_server=_env_bool("AGENTSPAN_AUTO_START_SERVER", True), daemon_workers=_env_bool("AGENTSPAN_DAEMON_WORKERS", True), auto_register_integrations=_env_bool("AGENTSPAN_INTEGRATIONS_AUTO_REGISTER", False), streaming_enabled=_env_bool("AGENTSPAN_STREAMING_ENABLED", True), - secret_strict_mode=_env_bool("AGENTSPAN_SECRET_STRICT_MODE", False), - log_level=log_level, + liveness_enabled=_env_bool("AGENTSPAN_LIVENESS_ENABLED", True), + liveness_stall_seconds=_env_float("AGENTSPAN_LIVENESS_STALL_SECONDS", 30.0), + liveness_check_interval_seconds=_env_float( + "AGENTSPAN_LIVENESS_CHECK_INTERVAL_SECONDS", 10.0 + ), ) - - @property - def api_secret(self) -> Optional[str]: - """Alias for :attr:`auth_secret` (industry-standard naming).""" - return self.auth_secret - - def to_conductor_configuration(self) -> "Configuration": # noqa: F821 - """Convert to a ``conductor-python`` :class:`Configuration` object.""" - from conductor.client.configuration.configuration import Configuration - - config = Configuration(server_api_url=self.server_url) - # Propagate our log level to the Conductor logger process. - # Configuration.log_level has no public setter (only debug=True/False), - # so we write the private attribute directly. - import logging as _logging - - config._Configuration__log_level = getattr(_logging, self.log_level.upper(), _logging.INFO) - # Prefer api_key; fall back to auth_key for backward compat - effective_key = self.api_key or self.auth_key - if effective_key: - from conductor.client.configuration.settings.authentication_settings import ( - AuthenticationSettings, - ) - - config.authentication_settings = AuthenticationSettings( - key_id=effective_key, - key_secret=self.auth_secret or "", - ) - return config diff --git a/src/conductor/ai/agents/runtime/credentials/__init__.py b/src/conductor/ai/agents/runtime/credentials/__init__.py index 58bf34f4..ab69580a 100644 --- a/src/conductor/ai/agents/runtime/credentials/__init__.py +++ b/src/conductor/ai/agents/runtime/credentials/__init__.py @@ -6,7 +6,6 @@ from __future__ import annotations from conductor.ai.agents.runtime.credentials.accessor import get_secret -from conductor.ai.agents.runtime.credentials.fetcher import WorkerCredentialFetcher from conductor.ai.agents.runtime.credentials.types import ( CredentialAuthError, CredentialNotFoundError, @@ -19,6 +18,5 @@ "CredentialAuthError", "CredentialRateLimitError", "CredentialServiceError", - "WorkerCredentialFetcher", "get_secret", ] diff --git a/src/conductor/ai/agents/runtime/credentials/fetcher.py b/src/conductor/ai/agents/runtime/credentials/fetcher.py deleted file mode 100644 index 04e5e9a3..00000000 --- a/src/conductor/ai/agents/runtime/credentials/fetcher.py +++ /dev/null @@ -1,132 +0,0 @@ -# Copyright (c) 2025 Agentspan -# Licensed under the MIT License. See LICENSE file in the project root for details. - -"""WorkerCredentialFetcher — resolves credentials for a Conductor task. - -Credentials are ALWAYS resolved from the server via POST /api/workers/credentials. -There is no env var fallback. If the execution token is missing or credentials -are not stored on the server, the tool fails with a non-retryable error. -""" - -from __future__ import annotations - -import logging -from typing import Dict, List, Optional - -import httpx - -from conductor.ai.agents.runtime.credentials.types import ( - CredentialAuthError, - CredentialNotFoundError, - CredentialRateLimitError, - CredentialServiceError, -) - -logger = logging.getLogger("conductor.ai.agents.credentials.fetcher") - - -class WorkerCredentialFetcher: - """Fetches credentials for a worker task execution. - - Args: - server_url: Base URL of the agentspan server API (e.g. ``"http://localhost:8080/api"``). - api_key: Optional Bearer token or API key for the Authorization header. - """ - - def __init__( - self, - server_url: str = "http://localhost:8080/api", - strict_mode: bool = False, - api_key: Optional[str] = None, - ) -> None: - self._server_url = server_url.rstrip("/") - self._strict_mode = strict_mode # kept for backwards compat but not used - self._api_key = api_key - - # ── Public API ────────────────────────────────────────────────────── - - def fetch( - self, - execution_token: Optional[str], - names: List[str], - ) -> Dict[str, str]: - """Resolve credential values for *names* from the server. - - Credentials are always fetched from the server. There is no env var - fallback. If the execution token is missing, this raises - CredentialNotFoundError so the task fails with a non-retryable error. - - Args: - execution_token: The ``__agentspan_ctx__`` token from Conductor task - variables. - names: Logical credential names to resolve (e.g. ``["GITHUB_TOKEN"]``). - - Returns: - Dict mapping credential name → plaintext value. - - Raises: - CredentialAuthError: Token expired/revoked (401). - CredentialRateLimitError: Rate limit hit (429). - CredentialServiceError: Server unreachable or 5xx. - CredentialNotFoundError: Credential(s) not found on server or no token. - """ - if not names: - return {} - - if not execution_token: - raise CredentialNotFoundError( - names, - "No execution token available. " - "Store credentials on the server with: agentspan credentials set --name ", - ) - - return self._fetch_from_server(execution_token, names) - - # ── Private helpers ───────────────────────────────────────────────── - - def _fetch_from_server( - self, - execution_token: str, - names: List[str], - ) -> Dict[str, str]: - # Server endpoint was renamed to /workers/secrets (Conductor parity); - # the SDK keeps the credentials terminology on the user-facing side. - url = f"{self._server_url}/workers/secrets" - headers: Dict[str, str] = {"Content-Type": "application/json"} - if self._api_key: - headers["Authorization"] = f"Bearer {self._api_key}" - - try: - with httpx.Client(timeout=httpx.Timeout(10.0, connect=5.0)) as client: - response = client.post( - url, - json={"token": execution_token, "names": names}, - headers=headers, - ) - except httpx.RequestError as exc: - logger.error("Credential service unreachable: %s", exc) - raise CredentialServiceError(0, str(exc)) from exc - - status = response.status_code - - if status == 401: - raise CredentialAuthError(response.text) - - if status == 429: - raise CredentialRateLimitError() - - if status >= 500: - raise CredentialServiceError(status, response.text) - - # 200 OK — check for missing credentials - resolved: Dict[str, str] = response.json() - missing = [n for n in names if n not in resolved] - if missing: - logger.error( - "Credentials not found on server: %s. " - "Store them with: agentspan credentials set --name ", - missing, - ) - raise CredentialNotFoundError(missing) - - return resolved diff --git a/src/conductor/ai/agents/runtime/http_client.py b/src/conductor/ai/agents/runtime/http_client.py deleted file mode 100644 index 4b5565f9..00000000 --- a/src/conductor/ai/agents/runtime/http_client.py +++ /dev/null @@ -1,658 +0,0 @@ -# Copyright (c) 2025 Agentspan -# Licensed under the MIT License. See LICENSE file in the project root for details. - -"""Agent-level client for the Agent Runtime API. - -The raw ``/agent/*`` HTTP transport lives in -:class:`conductor.client.ai.agent_api_client.AgentApiClient`; this module keeps -the agent-DX surface — :class:`AgentClient` composes the transport and adds -``run``/``start``/``deploy``/``schedule`` plus the :class:`AgentHandle` -plumbing (``_ClientRuntimeAdapter``). -""" - -from __future__ import annotations - -import asyncio -import logging -from typing import Any, AsyncIterator, Dict, List, Optional, Union - -# SSEUnavailableError is re-exported here for backward compatibility — it moved -# to conductor.client.ai with the transport. -from conductor.client.ai.agent_api_client import ( # noqa: F401 - AgentApiClient, - SSEUnavailableError, -) - -logger = logging.getLogger("conductor.ai.agents.runtime.http_client") - - -class AgentClient: - """Client for the Agent Runtime API. - - This is the ``/agent/*`` control-plane client (compile / deploy / start / - status / respond / stream). On top of those raw endpoints it gains - agent-level convenience methods — :meth:`run`, :meth:`start`, - :meth:`deploy`, :meth:`schedule` — and a :attr:`schedules` accessor for the - cron schedule lifecycle. - - **Run is control-plane only.** :meth:`run` compiles + starts the agent and - polls to a result; it does **not** register or poll local tool workers. - Agents that use local ``@tool`` functions must run through - :class:`AgentRuntime`, which owns worker orchestration. For LLM-only - agents, remote tools (HTTP/MCP), or pre-deployed workflows, this client is - sufficient. - - Two construction modes: - - - **Bound to a runtime** (``AgentClient(runtime=rt)``): reuses the - runtime's Conductor clients, schedule client, and result helpers so - there is a single shared schedule surface and no duplicated state. - - **Standalone** (``AgentClient(server_url=..., ...)``): builds its own - Conductor clients lazily for the schedule lifecycle and status/token - lookups. - """ - - def __init__( - self, - server_url: str = "", - api_key: str = "", - auth_key: str = "", - auth_secret: str = "", - *, - runtime: Any = None, - ) -> None: - # When bound to a runtime, inherit its connection settings so the two - # share a single schedule/result surface (no duplicated state). - self._runtime = runtime - explicit_override = bool(server_url or api_key or auth_key or auth_secret) - if runtime is not None: - cfg = runtime._config - server_url = server_url or cfg.server_url - api_key = api_key or (cfg.api_key or "") - auth_key = auth_key or (cfg.auth_key or "") - auth_secret = auth_secret or (cfg.auth_secret or "") - - self._server_url = server_url.rstrip("/") - self._api_key = api_key - self._auth_key = auth_key - self._auth_secret = auth_secret - - # All wire calls go through the relocated transport. When bound to a - # runtime with no credential overrides, reuse the runtime's - # Configuration so the two share one auth/token surface. - if runtime is not None and not explicit_override: - transport_configuration = runtime._conductor_config - else: - transport_configuration = self._build_transport_configuration() - self._api = AgentApiClient(transport_configuration, token=self._api_key or None) - - # Lazily-built Conductor clients + schedule client for standalone use. - self._orkes_clients: Any = None - self._workflow_client_instance: Any = None - self._schedule_client_instance: Any = None - - def _build_transport_configuration(self) -> Any: - """Conductor ``Configuration`` carrying exactly this client's credentials.""" - from conductor.client.configuration.configuration import Configuration - from conductor.client.configuration.settings.authentication_settings import ( - AuthenticationSettings, - ) - - configuration = Configuration(server_api_url=self._server_url or None) - if self._auth_key and self._auth_secret: - configuration.authentication_settings = AuthenticationSettings( - key_id=self._auth_key, key_secret=self._auth_secret - ) - else: - # Never inherit ambient CONDUCTOR_AUTH_* env credentials the caller - # didn't pass — anonymous construction must stay anonymous. - configuration.authentication_settings = None - return configuration - - @property - def _client(self) -> Any: - """The transport's underlying ``httpx.AsyncClient`` (test injection point).""" - return self._api._client - - @_client.setter - def _client(self, value: Any) -> None: - self._api._client = value - - async def _auth_headers(self) -> Dict[str, str]: - """``X-Authorization`` header for secured hosts (orkes); {} when anonymous.""" - return await self._api._auth_headers() - - async def _get_client(self) -> Any: - return await self._api._get_client() - - def _url(self, path: str) -> str: - return self._api._url(path) - - # ── Agent API endpoints (delegated to the transport) ───────────── - - async def start_agent(self, payload: Dict[str, Any]) -> Dict[str, Any]: - """POST /agent/start — start an agent execution.""" - return await self._api.start_agent(payload) - - async def deploy_agent(self, payload: Dict[str, Any]) -> Dict[str, Any]: - """POST /agent/deploy — deploy agent (compile + register, no execution).""" - return await self._api.deploy_agent(payload) - - async def compile_agent(self, config_json: Dict[str, Any]) -> Dict[str, Any]: - """POST /agent/compile — compile agent config to agent def.""" - return await self._api.compile_agent(config_json) - - async def get_status(self, execution_id: str) -> Dict[str, Any]: - """GET /agent/{id}/status — fetch execution status.""" - return await self._api.get_status(execution_id) - - async def respond(self, execution_id: str, body: Dict[str, Any]) -> None: - """POST /agent/{id}/respond — complete a pending human task.""" - await self._api.respond(execution_id, body) - - async def stop(self, execution_id: str) -> None: - """POST /agent/{id}/stop — graceful deterministic stop.""" - await self._api.stop(execution_id) - - async def signal(self, execution_id: str, message: str) -> None: - """POST /agent/{id}/signal — inject persistent context.""" - await self._api.signal(execution_id, message) - - async def stream_sse(self, execution_id: str) -> AsyncIterator[Dict[str, Any]]: - """GET /agent/stream/{id} — consume SSE events. - - Yields parsed event dicts. Auto-reconnects with ``Last-Event-ID`` - on connection drops. Raises :class:`SSEUnavailableError` if the - server doesn't support SSE or sends only heartbeats. - """ - async for sse_event in self._api.stream_sse(execution_id): - yield sse_event - - _parse_sse_async = staticmethod(AgentApiClient._parse_sse_async) - - # ── Conductor clients (lazy; reused from runtime when bound) ───── - - def _get_orkes_clients(self) -> Any: - """Build (or reuse the runtime's) ``OrkesClients`` for schedule/status.""" - if self._runtime is not None: - return self._runtime._clients - if self._orkes_clients is None: - from dataclasses import replace - - from conductor.ai.agents.runtime.config import AgentConfig - from conductor.client.orkes_clients import OrkesClients - - cfg = replace( - AgentConfig.from_env(), - server_url=self._server_url, - api_key=self._api_key or None, - auth_key=self._auth_key or None, - auth_secret=self._auth_secret or None, - ) - self._orkes_clients = OrkesClients(configuration=cfg.to_conductor_configuration()) - return self._orkes_clients - - @property - def _workflow_client(self) -> Any: - """Conductor workflow client (reused from runtime when bound).""" - if self._runtime is not None: - return self._runtime._workflow_client - if self._workflow_client_instance is None: - self._workflow_client_instance = self._get_orkes_clients().get_workflow_client() - return self._workflow_client_instance - - @property - def schedules(self) -> Any: - """Cron schedule lifecycle client (:class:`SchedulerClient`). - - Exposes ``get_schedule/save_schedule/get_all_schedules`` plus the - lifecycle methods ``pause/resume/delete/run_now/preview_next/ - reconcile``. When bound to a runtime, this is the *same* - :class:`SchedulerClient` instance the runtime uses — there is one - shared schedule surface, not two. - """ - if self._runtime is not None: - return self._runtime.schedules_client() - if self._schedule_client_instance is None: - self._schedule_client_instance = ( - self._get_orkes_clients().get_scheduler_client() - ) - return self._schedule_client_instance - - # ── Agent-level convenience (control-plane only — NO local workers) ── - - async def run_async( - self, - agent: Any, - prompt: "Union[str, Any]" = None, - *, - media: Optional[List[str]] = None, - session_id: Optional[str] = None, - idempotency_key: Optional[str] = None, - timeout: Optional[int] = None, - context: Optional[Dict[str, Any]] = None, - static_plan: Optional[Dict[str, Any]] = None, - ) -> Any: - """Compile + start an agent, then poll to an :class:`AgentResult`. - - **Control-plane only** — does NOT register or poll local tool workers. - Use :meth:`AgentRuntime.run` for agents with local ``@tool`` functions. - Suitable for LLM-only agents, remote tools (HTTP/MCP), and pre-deployed - agents. - """ - handle = await self.start_async( - agent, - prompt, - media=media, - session_id=session_id, - idempotency_key=idempotency_key, - timeout=timeout, - context=context, - static_plan=static_plan, - ) - return await handle.join_async(timeout=timeout) - - def run( - self, - agent: Any, - prompt: "Union[str, Any]" = None, - *, - media: Optional[List[str]] = None, - session_id: Optional[str] = None, - idempotency_key: Optional[str] = None, - timeout: Optional[int] = None, - context: Optional[Dict[str, Any]] = None, - static_plan: Optional[Dict[str, Any]] = None, - ) -> Any: - """Synchronous :meth:`run_async`.""" - return _run_sync( - self.run_async( - agent, - prompt, - media=media, - session_id=session_id, - idempotency_key=idempotency_key, - timeout=timeout, - context=context, - static_plan=static_plan, - ) - ) - - async def start_async( - self, - agent: Any, - prompt: "Union[str, Any]" = None, - *, - media: Optional[List[str]] = None, - session_id: Optional[str] = None, - idempotency_key: Optional[str] = None, - timeout: Optional[int] = None, - context: Optional[Dict[str, Any]] = None, - static_plan: Optional[Dict[str, Any]] = None, - ) -> Any: - """Compile + start an agent; return an :class:`AgentHandle`. No workers.""" - from conductor.ai.agents.config_serializer import AgentConfigSerializer - from conductor.ai.agents.result import AgentHandle - - prompt_str = prompt if isinstance(prompt, str) else (prompt or "") - config_json = AgentConfigSerializer().serialize(agent) - payload: Dict[str, Any] = { - "agentConfig": config_json, - "prompt": prompt_str, - "sessionId": session_id or "", - "media": media or [], - } - if context: - payload["context"] = context - if idempotency_key: - payload["idempotencyKey"] = idempotency_key - if timeout is not None: - payload["timeoutSeconds"] = timeout - if static_plan is not None: - payload["static_plan"] = static_plan - - data = await self.start_agent(payload) - execution_id = data.get("executionId", "") - logger.info( - "Started agent '%s' via control-plane (execution_id=%s)", agent.name, execution_id - ) - # Wrap in an AgentHandle backed by this client via a runtime-shaped - # adapter (no AgentRuntime, no local workers). - return AgentHandle(execution_id, _ClientRuntimeAdapter(self)) - - def start( - self, - agent: Any, - prompt: "Union[str, Any]" = None, - *, - media: Optional[List[str]] = None, - session_id: Optional[str] = None, - idempotency_key: Optional[str] = None, - timeout: Optional[int] = None, - context: Optional[Dict[str, Any]] = None, - static_plan: Optional[Dict[str, Any]] = None, - ) -> Any: - """Synchronous :meth:`start_async`.""" - return _run_sync( - self.start_async( - agent, - prompt, - media=media, - session_id=session_id, - idempotency_key=idempotency_key, - timeout=timeout, - context=context, - static_plan=static_plan, - ) - ) - - async def deploy_async(self, *agents: Any) -> List[Any]: - """Compile + register one or more agents (no execution, no workers).""" - from conductor.ai.agents.config_serializer import AgentConfigSerializer - from conductor.ai.agents.frameworks.serializer import detect_framework, serialize_agent - from conductor.ai.agents.result import DeploymentInfo - - if not agents: - raise ValueError("deploy() requires at least one agent.") - - results: List[Any] = [] - for agent in agents: - framework = detect_framework(agent) - if framework: - raw_config, _ = serialize_agent(agent) - payload = {"framework": framework, "rawConfig": raw_config} - else: - payload = {"agentConfig": AgentConfigSerializer().serialize(agent)} - data = await self.deploy_agent(payload) - registered_name = data.get("agentName", "") or getattr(agent, "name", "") - agent_name = getattr(agent, "name", registered_name) - results.append(DeploymentInfo(registered_name=registered_name, agent_name=agent_name)) - logger.info("Deployed agent '%s' as '%s'", agent_name, registered_name) - return results - - def deploy(self, *agents: Any) -> List[Any]: - """Synchronous :meth:`deploy_async`.""" - return _run_sync(self.deploy_async(*agents)) - - def schedule(self, agent: Any, schedules: Optional[List[Any]]) -> Any: - """Deploy *agent* and reconcile its cron *schedules* declaratively. - - Upserts the listed schedules and prunes any others for the agent. - Pass ``[]`` to purge all schedules; ``None`` to leave them untouched. - Returns the :class:`DeploymentInfo` for the deployed agent. - """ - info = self.deploy(agent)[0] - self.schedules.reconcile(agent.name, schedules) - return info - - # ── Runtime-compatible surface for AgentHandle (poll / build result) ── - # - # AgentHandle is normally backed by an AgentRuntime; a client-backed - # handle (from start()/run()) needs the same sync+async methods AgentHandle - # calls on its ``_runtime``. We expose them here. The raw async endpoint - # methods above (``get_status``/``respond``/``stop``) take an execution id - # and the same names are used by the runtime-compat surface below — the - # sync variants are the *_sync helpers, the async variants reuse them. - - def _status(self, execution_id: str, data: Dict[str, Any]) -> Any: - from conductor.ai.agents.result import AgentStatus - - return AgentStatus( - execution_id=execution_id, - is_complete=data.get("isComplete", False), - is_running=data.get("isRunning", False), - is_waiting=data.get("isWaiting", False), - output=data.get("output"), - status=data.get("status", "UNKNOWN"), - reason=data.get("reasonForIncompletion"), - pending_tool=data.get("pendingTool"), - ) - - async def get_status_async(self, execution_id: str) -> Any: - """Fetch current status as an :class:`AgentStatus` (async).""" - return self._status(execution_id, await self.get_status(execution_id)) - - def get_status_sync(self, execution_id: str) -> Any: - """Fetch current status as an :class:`AgentStatus` (sync).""" - return _run_sync(self.get_status_async(execution_id)) - - async def respond_async(self, execution_id: str, output: Any) -> None: - """Complete a pending human task (async).""" - body = output if isinstance(output, dict) else {"output": output} - await self.respond(execution_id, body) - - async def stop_async(self, execution_id: str) -> None: - """Gracefully stop an execution (async).""" - await self.stop(execution_id) - - def _extract_token_usage(self, execution_id: str) -> Any: - """Fetch aggregated token usage from the full execution tree.""" - from conductor.ai.agents.result import TokenUsage - - if not execution_id: - return None - prompt, completion, total, found = self._collect_tokens_by_id(execution_id, set()) - if not found: - return None - if total == 0 and (prompt > 0 or completion > 0): - total = prompt + completion - return TokenUsage(prompt_tokens=prompt, completion_tokens=completion, total_tokens=total) - - def _collect_tokens_by_id(self, execution_id: str, visited: set) -> tuple: - """Recursively collect token counts via GET /api/agent/execution/{id}.""" - if execution_id in visited: - return 0, 0, 0, False - visited.add(execution_id) - - try: - data = self._api.get_execution_sync(execution_id) - except Exception: - return 0, 0, 0, False - - total_prompt = total_completion = total_total = 0 - found_any = False - token_usage = data.get("tokenUsage") - if token_usage: - p = int(token_usage.get("promptTokens", 0)) - c = int(token_usage.get("completionTokens", 0)) - t = int(token_usage.get("totalTokens", 0)) - if p or c or t: - found_any = True - total_prompt, total_completion, total_total = p, c, t - for task in data.get("tasks", []): - if "SUB_WORKFLOW" in str(task.get("taskType", "")).upper(): - sub_id = task.get("subWorkflowId") - if sub_id and sub_id not in visited: - p, c, t, f = self._collect_tokens_by_id(sub_id, visited) - if f: - found_any = True - total_prompt += p - total_completion += c - total_total += t - return total_prompt, total_completion, total_total, found_any - - def _sync_headers(self) -> Dict[str, str]: - """Build X-Authorization headers for synchronous ``requests`` calls.""" - return self._api._sync_headers() - - @staticmethod - def _normalize_output( - output: Any, raw_status: str, reason: Optional[str] = None - ) -> Dict[str, Any]: - """Normalize execution output to always be a dict. - - Delegates to :meth:`AgentRuntime._normalize_output` so the contract - stays identical across the worker-managed and control-plane paths. - """ - from conductor.ai.agents.runtime.runtime import AgentRuntime - - return AgentRuntime._normalize_output(output, raw_status, reason) - - @staticmethod - def _derive_finish_reason(raw_status: str, output: Any) -> Any: - """Derive a :class:`FinishReason` (delegates to AgentRuntime).""" - from conductor.ai.agents.runtime.runtime import AgentRuntime - - return AgentRuntime._derive_finish_reason(raw_status, output) - - # ── Lifecycle ──────────────────────────────────────────────────── - - async def close(self) -> None: - """Close the underlying httpx client.""" - await self._api.close() - - -def _run_sync(coro: Any) -> Any: - """Run a coroutine from a sync context, handling nested event loops.""" - try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = None - - if loop is not None and loop.is_running(): - import concurrent.futures - - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - return pool.submit(asyncio.run, coro).result() - return asyncio.run(coro) - - -class _ClientRuntimeAdapter: - """Adapts an :class:`AgentClient` to the runtime surface :class:`AgentHandle` expects. - - :class:`AgentHandle` was written against :class:`AgentRuntime`. For a - control-plane handle (returned by :meth:`AgentClient.start`) there is no - runtime — this thin shim provides the same sync+async methods AgentHandle - calls (``get_status`` / ``get_status_async`` / ``respond`` / ``stop`` / - ``_normalize_output`` / ``_derive_finish_reason`` / ``_extract_token_usage``) - by delegating to the client. It does NOT manage workers; ``_config`` is - absent so AgentHandle's liveness monitor stays disabled. - """ - - def __init__(self, client: "AgentClient") -> None: - self._client = client - self._workflow_client = client._workflow_client - - # ── status (sync + async) ── - def get_status(self, execution_id: str) -> Any: - return self._client.get_status_sync(execution_id) - - async def get_status_async(self, execution_id: str) -> Any: - return await self._client.get_status_async(execution_id) - - # ── HITL / control (sync + async) ── - def respond(self, execution_id: str, output: Any) -> None: - _run_sync(self._client.respond_async(execution_id, output)) - - async def respond_async(self, execution_id: str, output: Any) -> None: - await self._client.respond_async(execution_id, output) - - def stop(self, execution_id: str) -> None: - _run_sync(self._client.stop_async(execution_id)) - - async def stop_async(self, execution_id: str) -> None: - await self._client.stop_async(execution_id) - - def pause(self, execution_id: str) -> None: - self._workflow_client.pause_workflow(execution_id) - - async def pause_async(self, execution_id: str) -> None: - _run_sync(asyncio.sleep(0)) # keep coroutine semantics - self._workflow_client.pause_workflow(execution_id) - - def _resume_workflow(self, execution_id: str) -> None: - self._workflow_client.resume_workflow(execution_id) - - async def _resume_workflow_async(self, execution_id: str) -> None: - self._workflow_client.resume_workflow(execution_id) - - def cancel(self, execution_id: str, reason: str = "") -> None: - self._workflow_client.terminate_workflow(workflow_id=execution_id, reason=reason) - - async def cancel_async(self, execution_id: str, reason: str = "") -> None: - self._workflow_client.terminate_workflow(workflow_id=execution_id, reason=reason) - - # ── streaming (delegates to the client's SSE endpoint) ── - def _stream_workflow(self, execution_id: str): - from conductor.ai.agents.result import AgentEvent, EventType - - async def _aiter(): - async for sse in self._client.stream_sse(execution_id): - yield sse - - # Bridge async SSE → sync iterator for AgentHandle.stream(). - gen = _aiter() - - def _sync_iter(): - loop = asyncio.new_event_loop() - try: - while True: - try: - sse = loop.run_until_complete(gen.__anext__()) - except StopAsyncIteration: - return - ev = _sse_to_event(sse, execution_id, AgentEvent, EventType) - if ev is not None: - yield ev - finally: - loop.close() - - return _sync_iter() - - async def _stream_workflow_async(self, execution_id: str): - from conductor.ai.agents.result import AgentEvent, EventType - - async for sse in self._client.stream_sse(execution_id): - ev = _sse_to_event(sse, execution_id, AgentEvent, EventType) - if ev is not None: - yield ev - - # ── result helpers (delegate to client / AgentRuntime statics) ── - def _normalize_output(self, output: Any, raw_status: str, reason: Optional[str] = None) -> Any: - return self._client._normalize_output(output, raw_status, reason) - - def _derive_finish_reason(self, raw_status: str, output: Any) -> Any: - return self._client._derive_finish_reason(raw_status, output) - - def _extract_token_usage(self, execution_id: str) -> Any: - return self._client._extract_token_usage(execution_id) - - -def _sse_to_event(sse: Dict[str, Any], execution_id: str, AgentEvent: Any, EventType: Any) -> Any: - """Map a raw SSE event dict to an :class:`AgentEvent` (minimal mapping).""" - event_type = sse.get("event") - data = sse.get("data") or {} - if not isinstance(data, dict): - data = {"content": data} - type_map = { - "thinking": EventType.THINKING, - "tool_call": EventType.TOOL_CALL, - "tool_result": EventType.TOOL_RESULT, - "handoff": EventType.HANDOFF, - "waiting": EventType.WAITING, - "message": EventType.MESSAGE, - "error": EventType.ERROR, - "done": EventType.DONE, - "guardrail_pass": EventType.GUARDRAIL_PASS, - "guardrail_fail": EventType.GUARDRAIL_FAIL, - } - mapped = type_map.get(event_type) - if mapped is None: - return None - return AgentEvent( - type=mapped, - content=data.get("content") or data.get("message"), - tool_name=data.get("toolName") or data.get("tool_name"), - args=data.get("args"), - result=data.get("result"), - target=data.get("target"), - output=data.get("output"), - execution_id=execution_id, - guardrail_name=data.get("guardrailName") or data.get("guardrail_name"), - ) - - -# ── Backward-compatibility alias ──────────────────────────────────────── -# ``AgentHttpClient`` was renamed to ``AgentClient``; keep the old name so -# existing imports (``from ...http_client import AgentHttpClient``) still work. -AgentHttpClient = AgentClient diff --git a/src/conductor/ai/agents/runtime/runtime.py b/src/conductor/ai/agents/runtime/runtime.py index 21ce216b..0c54363e 100644 --- a/src/conductor/ai/agents/runtime/runtime.py +++ b/src/conductor/ai/agents/runtime/runtime.py @@ -20,10 +20,14 @@ import threading import time import uuid -from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Union +from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, Iterator, List, Optional, Union + +if TYPE_CHECKING: + from conductor.ai.agents.runtime.config import AgentConfig + from conductor.client.configuration.configuration import Configuration from conductor.ai.agents.agent import Agent -from conductor.ai.agents.exceptions import _raise_api_error +from conductor.ai.agents.run_settings import RunSettings from conductor.ai.agents.result import ( AgentEvent, AgentHandle, @@ -36,7 +40,7 @@ FinishReason, TokenUsage, ) -from conductor.ai.agents.runtime.http_client import AgentClient, SSEUnavailableError +from conductor.client.agent_client import SSEUnavailableError logger = logging.getLogger("conductor.ai.agents.runtime") @@ -63,6 +67,20 @@ def _resolve_retry_logic(policy: str) -> str: ) +def _credential_names(credentials: Any) -> List[str]: + """Extract the string secret names from a tool/agent ``credentials`` list. + + Entries may be plain names or objects exposing a ``name`` attribute; anything + else is ignored. Order-preserving and de-duplicated. + """ + names: List[str] = [] + for c in credentials or []: + n = c if isinstance(c, str) else getattr(c, "name", None) + if isinstance(n, str) and n and n not in names: + names.append(n) + return names + + def _default_task_def( name: str, *, @@ -70,6 +88,7 @@ def _default_task_def( retry_count: int = 2, retry_delay_seconds: int = 2, retry_policy: str = "linear_backoff", + runtime_metadata: Optional[List[str]] = None, ) -> Any: """Create a TaskDef with standard retry policy for agent worker tasks. @@ -80,6 +99,11 @@ def _default_task_def( within this time, Conductor marks the task as timed out and retries. Kept short to detect dead workers quickly; lease extension heartbeats (at 80% of this value) keep long-running tasks alive automatically. + + runtime_metadata: declared secret names the host resolves at poll time and + delivers on the wire-only ``Task.runtimeMetadata``. Stamped here so the + SDK's registration (``overwrite_task_def=True``) does not wipe the value + the server compiles from the tool's credentials. """ from conductor.client.http.models.task_def import TaskDef @@ -90,16 +114,19 @@ def _default_task_def( td.timeout_seconds = 0 td.response_timeout_seconds = response_timeout_seconds td.timeout_policy = "RETRY" + if runtime_metadata: + td.runtime_metadata = list(runtime_metadata) return td -def _passthrough_task_def(name: str) -> Any: +def _passthrough_task_def(name: str, *, runtime_metadata: Optional[List[str]] = None) -> Any: """Create a TaskDef for framework passthrough workers. Timeout is 0 (no timeout) — the agent configuration controls execution duration, not the task definition. response_timeout_seconds is 10s: same reasoning as _default_task_def. + ``runtime_metadata``: see :func:`_default_task_def`. """ from conductor.client.http.models.task_def import TaskDef @@ -110,6 +137,8 @@ def _passthrough_task_def(name: str) -> Any: td.timeout_seconds = 0 td.response_timeout_seconds = 10 td.timeout_policy = "RETRY" + if runtime_metadata: + td.runtime_metadata = list(runtime_metadata) return td @@ -238,7 +267,7 @@ def _normalize_handoff_target(task_ref: str) -> str: return name -# Backward compat alias — SSEUnavailableError is now in http_client +# Backward compat alias — SSEUnavailableError is now in conductor.client.agent_client _SSEUnavailableError = SSEUnavailableError @@ -317,66 +346,45 @@ class AgentRuntime: result = runtime.run(agent, "Hello!") print(result.output) - Connection params can be passed directly or loaded from environment - variables (``AGENTSPAN_SERVER_URL``, ``AGENTSPAN_AUTH_KEY``, - ``AGENTSPAN_AUTH_SECRET``). + Like every other client, server connection comes from the standard + :class:`~conductor.client.configuration.configuration.Configuration` + (``CONDUCTOR_SERVER_URL`` → ``AGENTSPAN_SERVER_URL``, ``CONDUCTOR_AUTH_KEY``/ + ``CONDUCTOR_AUTH_SECRET`` when not passed explicitly):: + + from conductor.client.configuration.configuration import Configuration + + with AgentRuntime(Configuration(server_api_url="https://prod:8080/api")) as runtime: + ... Args: - server_url: AgentSpan server API URL. Overrides env and *config*. - api_key: Agentspan auth key. Overrides env and *config*. - api_secret: Agentspan auth secret. Overrides env and *config*. - config: Optional :class:`AgentConfig` for full control over all - settings. Explicit keyword params take precedence over values - in *config*. + configuration: The conductor :class:`Configuration` (host + auth). + Defaults to ``Configuration()``, which resolves from the environment. + settings: Optional :class:`AgentConfig` with runtime *behaviour* knobs + (worker threads/polling, streaming, integrations, log level). + Defaults to :meth:`AgentConfig.from_env`. Connection fields on it + are ignored — *configuration* is the single source of server config. """ def __init__( self, + configuration: Optional[Configuration] = None, *, - server_url: Optional[str] = None, - api_key: Optional[str] = None, - api_secret: Optional[str] = None, - config: Optional[Any] = None, + settings: Optional[AgentConfig] = None, ) -> None: - from dataclasses import replace - from conductor.ai.agents.runtime.config import AgentConfig + from conductor.client.configuration.configuration import Configuration - base = config if config is not None else AgentConfig.from_env() - overrides: dict = {} - if server_url is not None: - overrides["server_url"] = server_url - if api_key is not None: - overrides["api_key"] = api_key - if api_secret is not None: - overrides["auth_secret"] = api_secret - self._config = replace(base, **overrides) if overrides else base - - # Auto-start the server if it targets localhost and is not responding. - if self._config.auto_start_server: - from conductor.ai.agents.runtime.server import ensure_server_running - - ensure_server_running(self._config.server_url) - else: - # Fail fast with a clear message when auto-start is disabled - # and the server is unreachable. - from conductor.ai.agents.runtime.server import _is_server_ready - - if not _is_server_ready(self._config.server_url): - import sys - - print( - f"\n[agentspan] Error: Cannot connect to the Agentspan server at " - f"{self._config.server_url}\n" - f"[agentspan] The server does not appear to be running and " - f"auto_start_server is disabled.\n" - f"[agentspan] Please ensure the server is running at the configured " - f"URL, or remove AGENTSPAN_AUTO_START_SERVER=false to start it automatically.", - file=sys.stderr, - ) - raise SystemExit(1) + # Server config (host, auth, token management) comes exclusively from + # the standard conductor Configuration — the same object ApiClient uses. + self._conductor_config = configuration if configuration is not None else Configuration() + # Runtime behaviour knobs only; connection fields are ignored. + self._config = settings if settings is not None else AgentConfig.from_env() - self._conductor_config = self._config.to_conductor_configuration() + # Convenience credentials for worker subprocesses (picklable strings — + # a live ApiClient can't cross a process boundary). + auth = self._conductor_config.authentication_settings + self._auth_key = (auth.key_id if auth else "") or "" + self._auth_secret = (auth.key_secret if auth else "") or "" from conductor.client.orkes_clients import OrkesClients @@ -407,19 +415,16 @@ def __init__( self._integration_api_available: Optional[bool] = None self._sse_fallback_warned = False - # Apply user-configured log level to all conductor.ai loggers - logging.getLogger("conductor.ai").setLevel( - getattr(logging, self._config.log_level.upper(), logging.INFO) - ) + # Apply the SDK-wide log level (from Configuration) to conductor.ai loggers + logging.getLogger("conductor.ai").setLevel(self._conductor_config.log_level) - # Control-plane client for the agent API. Bound to this runtime so it - # shares the runtime's Conductor clients and schedule surface. It is - # both the async HTTP client (compile/start/status/respond/stream) and - # the control-plane run/deploy/schedule entry point exposed via - # :attr:`client`. - self._http = AgentClient(runtime=self) + # Control-plane client for the agent API (start/deploy/compile/status/ + # execution/respond/stop/signal/SSE). Built from the same Conductor + # clients (and hence the same Configuration + token cache) as the rest of + # the runtime, and exposed via :attr:`client`. + self._agent_client = self._clients.get_agent_client() - logger.info("AgentRuntime initialized (server=%s)", self._config.server_url) + logger.info("AgentRuntime initialized (server=%s)", self._conductor_config.host) # ── Sync/async bridge ──────────────────────────────────────────── @@ -444,40 +449,6 @@ def _run_sync(coro: Any) -> Any: # ── Agent Runtime API helpers ──────────────────────────────────── - def _agent_api_url(self, path: str) -> str: - """Build a URL for the agent runtime API.""" - base = self._config.server_url.rstrip("/") - return f"{base}/agent{path}" - - def _agent_api_token(self) -> "Optional[str]": - """Resolve the bearer token for agent runtime API calls (sent as X-Authorization). - - Prefers an explicit ``api_key`` (already a token). Otherwise mints and caches a JWT - from ``auth_key``/``auth_secret`` via ``POST {server}/token`` — the orkes internal-key - (service-account) auth path, the same exchange the worker client and CLI use. Returns - ``None`` when no credentials are configured (anonymous / security-disabled servers). - """ - from conductor.ai.agents._internal.token_utils import resolve_agent_api_token - - return resolve_agent_api_token( - self._config.server_url, - api_key=self._config.api_key, - auth_key=self._config.auth_key, - auth_secret=self._config.auth_secret, - ) - - def _agent_api_headers(self, content_type: str = "application/json") -> Dict[str, str]: - """Build headers for agent runtime API requests.""" - headers: Dict[str, str] = {} - if content_type: - headers["Content-Type"] = content_type - token = self._agent_api_token() - if token: - # orkes accepts X-Authorization (and Authorization: Bearer); the standalone - # anonymous server ignores it. X-Authorization matches the UI/CLI convention. - headers["X-Authorization"] = token - return headers - def _register_workflow_credentials( self, execution_id: str, credentials: Optional[List[str]] ) -> None: @@ -568,6 +539,7 @@ def _start_via_server( context: Optional[Dict[str, Any]] = None, run_id: Optional[str] = None, static_plan: Optional[Dict[str, Any]] = None, + run_settings: Optional[Union[RunSettings, dict]] = None, ) -> str: """Start an agent via the server's /api/agent/start endpoint. @@ -577,8 +549,6 @@ def _start_via_server( Returns: The execution ID. """ - import requests as req_lib - pre_deployed_skills = self._pre_deploy_nested_skills(agent) from conductor.ai.agents.config_serializer import AgentConfigSerializer @@ -586,6 +556,13 @@ def _start_via_server( serializer = AgentConfigSerializer() config_json = serializer.serialize(agent) + # Per-run LLM overrides (model/temperature/…) mutate the serialized + # agentConfig before compile+register+start, so they flow into the + # LLM tasks without a new server field. + rs = RunSettings.coerce(run_settings) + if rs is not None: + config_json.update(rs.to_config_overrides()) + payload = { "agentConfig": config_json, "prompt": prompt, @@ -608,13 +585,7 @@ def _start_via_server( # discarded when this is set. payload["static_plan"] = static_plan - url = self._agent_api_url("/start") - resp = req_lib.post(url, json=payload, headers=self._agent_api_headers(), timeout=30) - try: - resp.raise_for_status() - except req_lib.exceptions.HTTPError as exc: - _raise_api_error(exc, url=url) - data = resp.json() + data = self._agent_client.start_agent(payload) execution_id = data.get("executionId", "") required_workers: Optional[set] = None if "requiredWorkers" in data: @@ -641,6 +612,7 @@ async def _start_via_server_async( credentials: Optional[List[str]] = None, context: Optional[Dict[str, Any]] = None, run_id: Optional[str] = None, + run_settings: Optional[Union[RunSettings, dict]] = None, ) -> str: """Async version of :meth:`_start_via_server`.""" pre_deployed_skills = self._pre_deploy_nested_skills(agent) @@ -650,6 +622,11 @@ async def _start_via_server_async( serializer = AgentConfigSerializer() config_json = serializer.serialize(agent) + # Per-run LLM overrides (see :meth:`_start_via_server`). + rs = RunSettings.coerce(run_settings) + if rs is not None: + config_json.update(rs.to_config_overrides()) + payload = { "agentConfig": config_json, "prompt": prompt, @@ -667,7 +644,7 @@ async def _start_via_server_async( if run_id: payload["runId"] = run_id - data = await self._http.start_agent(payload) + data = await self._agent_client.start_agent_async(payload) execution_id = data.get("executionId", "") required_workers: Optional[set] = None if "requiredWorkers" in data: @@ -708,7 +685,7 @@ async def _start_framework_via_server_async( if credentials: payload["credentials"] = credentials - data = await self._http.start_agent(payload) + data = await self._agent_client.start_agent_async(payload) execution_id = data.get("executionId", "") logger.info( "Started %s framework agent via server (execution_id=%s)", @@ -757,25 +734,12 @@ def _compile_via_server(self, agent: Agent) -> Any: raw ``WorkflowDef`` that implements the interface the runtime needs (``to_workflow_def()``, ``start_workflow_with_input()``). """ - import requests - from conductor.ai.agents.config_serializer import AgentConfigSerializer serializer = AgentConfigSerializer() config_json = serializer.serialize(agent) - server_url = self._config.server_url.rstrip("/") - url = f"{server_url}/agent/compile" - - headers = self._agent_api_headers() - - payload = {"agentConfig": config_json} - response = requests.post(url, json=payload, headers=headers, timeout=30) - try: - response.raise_for_status() - except requests.exceptions.HTTPError as exc: - _raise_api_error(exc, url=url) - data = response.json() + data = self._agent_client.compile_agent({"agentConfig": config_json}) workflow_def_dict = data.get("workflowDef", data) @@ -791,7 +755,7 @@ async def _compile_via_server_async(self, agent: Agent) -> Any: serializer = AgentConfigSerializer() config_json = serializer.serialize(agent) - data = await self._http.compile_agent({"agentConfig": config_json}) + data = await self._agent_client.compile_agent_async({"agentConfig": config_json}) workflow_def_dict = data.get("workflowDef", data) return ServerCompiledWorkflow( @@ -1121,9 +1085,9 @@ def _server_needs(task_name: str) -> bool: worker_fn = make_claude_agent_sdk_worker( cc_opts, agent.name, - self._config.server_url, - self._config.auth_key or "", - self._config.auth_secret or "", + self._conductor_config.host, + self._auth_key, + self._auth_secret, ) worker = WorkerInfo( name=agent.name, @@ -1290,9 +1254,9 @@ def _server_needs(task_name: str) -> bool: worker_func = make_claude_agent_sdk_worker( cc_options, sub.name, - self._config.server_url, - self._config.auth_key or "", - self._config.auth_secret or "", + self._conductor_config.host, + self._auth_key, + self._auth_secret, ) worker = WorkerInfo( name=sub.name, @@ -1593,7 +1557,7 @@ def _register_hybrid_transfer_workers( for sub in agent.agents: tool_name = f"{agent.name}_transfer_to_{sub.name}" transfer_worker = TransferNoopEntry() - transfer_worker.__annotations__ = {"return": object} + transfer_worker.__annotations__ = {"message": str, "return": object} probe_spawn_safety(transfer_worker, tool_name, group="system") worker_task( task_definition_name=tool_name, @@ -1721,7 +1685,7 @@ def _register_swarm_transfer_workers( transfer_worker.__annotations__ = {"return": str} else: transfer_worker = TransferNoopEntry() - transfer_worker.__annotations__ = {"return": object} + transfer_worker.__annotations__ = {"message": str, "return": object} probe_spawn_safety(transfer_worker, tool_name, group="system") worker_task( task_definition_name=tool_name, @@ -2092,8 +2056,6 @@ def plan(self, agent: Agent) -> Any: The raw server response dict with ``workflowDef`` and ``requiredWorkers`` keys. """ - import requests - from conductor.ai.agents.frameworks.serializer import detect_framework framework = detect_framework(agent) @@ -2112,17 +2074,7 @@ def plan(self, agent: Agent) -> Any: config_json = serializer.serialize(agent) payload = {"agentConfig": config_json} - server_url = self._config.server_url.rstrip("/") - url = f"{server_url}/agent/compile" - - headers = self._agent_api_headers() - - response = requests.post(url, json=payload, headers=headers, timeout=30) - try: - response.raise_for_status() - except requests.exceptions.HTTPError as exc: - _raise_api_error(exc, url=url) - return response.json() + return self._agent_client.compile_agent(payload) # ── Deploy (CI/CD) ───────────────────────────────────────────── @@ -2222,13 +2174,13 @@ async def deploy_async( def client(self) -> Any: """The control-plane :class:`AgentClient` for this runtime. - Exposes ``run``/``run_async``, ``start``/``start_async``, ``deploy``, - ``schedule``, the schedule lifecycle (:attr:`AgentClient.schedules`), - and the raw ``/agent/*`` endpoints. **Control-plane only** — its - ``run`` does NOT manage local tool workers (use :meth:`run` on the - runtime for agents with local ``@tool`` functions). + Exposes the ``/agent/*`` endpoints (start/deploy/compile/status/ + execution/respond/stop/signal/SSE) and the schedule lifecycle + (:attr:`AgentClient.schedules`). Built on the shared ``ApiClient`` token + machinery. For running agents with local ``@tool`` functions use + :meth:`run` on the runtime, which manages workers. """ - return self._http + return self._agent_client def schedules_client(self) -> Any: """Return the shared :class:`SchedulerClient` for this runtime. @@ -2242,8 +2194,6 @@ def schedules_client(self) -> Any: def _deploy_via_server(self, agent: Any, *, framework: Optional[str] = None) -> str: """Deploy agent via /api/agent/deploy. Returns registered name.""" - import requests as req_lib - if framework: from conductor.ai.agents.frameworks.serializer import serialize_agent @@ -2258,13 +2208,7 @@ def _deploy_via_server(self, agent: Any, *, framework: Optional[str] = None) -> serializer = AgentConfigSerializer() payload = {"agentConfig": serializer.serialize(agent)} - url = self._agent_api_url("/deploy") - resp = req_lib.post(url, json=payload, headers=self._agent_api_headers(), timeout=30) - try: - resp.raise_for_status() - except req_lib.exceptions.HTTPError as exc: - _raise_api_error(exc, url=url) - deploy_data = resp.json() + deploy_data = self._agent_client.deploy_agent(payload) return deploy_data.get("agentName", "") async def _deploy_via_server_async(self, agent: Any, *, framework: Optional[str] = None) -> str: @@ -2283,7 +2227,7 @@ async def _deploy_via_server_async(self, agent: Any, *, framework: Optional[str] serializer = AgentConfigSerializer() payload = {"agentConfig": serializer.serialize(agent)} - data = await self._http.deploy_agent(payload) + data = await self._agent_client.deploy_agent_async(payload) return data.get("agentName", "") # ── Serve (runtime worker service) ───────────────────────────── @@ -2314,11 +2258,13 @@ def serve( packages: Optional[List[str]] = None, blocking: bool = True, ) -> None: - """Register workers and keep them polling until interrupted. + """Register the agent(s) on the server, serve their workers, and poll. - This is a runtime operation: it registers the Python tool functions - (tools, custom guardrails, callbacks, handoff checks, etc.) as - Conductor workers and starts polling for tasks. + This is a runtime operation: for each agent it registers the agent on + the server (compile + register the workflow and task defs, like + :meth:`deploy`), then registers the local Python tool functions (tools, + custom guardrails, callbacks, handoff checks, etc.) as Conductor workers + and starts polling for tasks. In short, ``serve`` = ``deploy`` + serve. Args: *agents: Agents whose workers should be served. @@ -2347,6 +2293,10 @@ def serve( has_new = False for agent in all_agents: framework = detect_framework(agent) + # Register the agent (workflow + task defs) on the server before + # bringing up local workers. Mirrors run()'s deploy-then-register + # order; _register_workers' overwrite_task_def keeps it idempotent. + self._deploy_via_server(agent, framework=framework) if framework is not None: self._serve_framework_workers(agent, framework) has_new = True @@ -2443,6 +2393,7 @@ def run( timeout: Optional[int] = None, credentials: Optional[List[str]] = None, context: Optional[Dict[str, Any]] = None, + run_settings: Optional[Union[RunSettings, dict]] = None, **kwargs: Any, ) -> AgentResult: """Execute an agent synchronously and return the result. @@ -2564,6 +2515,7 @@ def run( context=context, run_id=run_id, static_plan=static_plan, + run_settings=run_settings, ) worker_domain = self._resolve_worker_domain(execution_id, run_id) @@ -3026,8 +2978,6 @@ def _start_framework_via_server( context: Optional[Dict[str, Any]] = None, ) -> str: """POST to /api/agent/start with framework + rawConfig.""" - import requests as req_lib - payload: Dict[str, Any] = { "framework": framework, "rawConfig": raw_config, @@ -3041,13 +2991,7 @@ def _start_framework_via_server( if credentials: payload["credentials"] = credentials - url = self._agent_api_url("/start") - resp = req_lib.post(url, json=payload, headers=self._agent_api_headers(), timeout=30) - try: - resp.raise_for_status() - except req_lib.exceptions.HTTPError as exc: - _raise_api_error(exc, url=url) - data = resp.json() + data = self._agent_client.start_agent(payload) execution_id = data.get("executionId", "") logger.info( "Started %s framework agent via server (execution_id=%s)", @@ -3076,7 +3020,7 @@ def _register_framework_workers( probe_spawn_safety(wrapper, w.name, group="tools") worker_task( task_definition_name=w.name, - task_def=_default_task_def(w.name), + task_def=_default_task_def(w.name, runtime_metadata=_credential_names(credentials)), register_task_def=True, overwrite_task_def=True, lease_extend_enabled=True, @@ -3117,9 +3061,13 @@ def _register_passthrough_worker(self, worker: Any) -> None: worker.func.__annotations__ = {"task": object, "return": object} probe_spawn_safety(worker.func, worker.name, group="framework") + # Credentials are baked into the passthrough entry (PassthroughWorkerEntry. + # credential_names); stamp them onto the TaskDef so the host resolves them + # at poll time and this registration doesn't wipe them. + cred_names = _credential_names(getattr(worker.func, "credential_names", None)) worker_task( task_definition_name=worker.name, - task_def=_passthrough_task_def(worker.name), + task_def=_passthrough_task_def(worker.name, runtime_metadata=cred_names), register_task_def=True, overwrite_task_def=True, thread_count=self._config.worker_thread_count, @@ -3229,9 +3177,9 @@ def _build_passthrough_func( self, agent_obj: Any, framework: str, name: str, credentials: Optional[List[str]] = None ) -> Any: """Build the pre-wrapped tool_worker function for a passthrough worker.""" - server_url = self._config.server_url - auth_key = self._config.auth_key or "" - auth_secret = self._config.auth_secret or "" + server_url = self._conductor_config.host + auth_key = self._auth_key + auth_secret = self._auth_secret # All three return a picklable PassthroughWorkerEntry (idea-5 spawn # safety) that rebuilds the real worker per process. For langgraph/ @@ -3505,129 +3453,23 @@ def _stream_sse(self, execution_id: str) -> Iterator[AgentEvent]: :class:`AgentEvent` objects as they arrive. Auto-reconnects with ``Last-Event-ID`` if the connection drops. - If the server connects but only sends heartbeats (no real events) - for ``_SSE_NO_EVENT_TIMEOUT`` seconds, raises - :class:`_SSEUnavailableError` so the caller can fall back to polling. + If the server connects but only sends heartbeats (no real events), + the underlying client raises :class:`_SSEUnavailableError` so the caller + can fall back to polling. Raises: _SSEUnavailableError: If the server doesn't support SSE (non-200 response, connection timeout, or heartbeat-only stream). """ - import requests - - _SSE_NO_EVENT_TIMEOUT = 15 # seconds to wait for first real event - - server_url = self._config.server_url.rstrip("/") - url = f"{server_url}/agent/stream/{execution_id}" - headers: Dict[str, str] = {"Accept": "text/event-stream"} - token = self._agent_api_token() - if token: - headers["X-Authorization"] = token - - last_event_id: Optional[str] = None - first_connect = True - got_real_event = False - - while True: - try: - req_headers = dict(headers) - if last_event_id is not None: - req_headers["Last-Event-ID"] = last_event_id - - with requests.get(url, headers=req_headers, stream=True, timeout=(5, 30)) as resp: - if resp.status_code != 200: - if first_connect: - raise _SSEUnavailableError(f"Server returned {resp.status_code}") - # Reconnection failed — stop - logger.warning( - "SSE reconnect failed (status=%s), stopping stream", - resp.status_code, - ) - return - - first_connect = False - connect_time = time.monotonic() - - for sse_event in self._parse_sse(resp.iter_lines()): - # Heartbeat marker — check timeout - if sse_event.get("_heartbeat"): - if ( - not got_real_event - and time.monotonic() - connect_time > _SSE_NO_EVENT_TIMEOUT - ): - raise _SSEUnavailableError( - "SSE connected but no events received " - f"(only heartbeats for " - f"{_SSE_NO_EVENT_TIMEOUT}s)" - ) - continue - - if sse_event.get("id"): - last_event_id = sse_event["id"] - - agent_event = self._sse_to_agent_event(sse_event, execution_id) - if agent_event is not None: - got_real_event = True - yield agent_event - if agent_event.type in ( - EventType.DONE, - EventType.ERROR, - ): - return - - # Stream ended cleanly (server completed the emitter) - return - - except _SSEUnavailableError: - raise - except Exception as e: - if first_connect: - raise _SSEUnavailableError(str(e)) - logger.warning("SSE connection lost (%s), reconnecting in 1s...", e) - time.sleep(1) - - @staticmethod - def _parse_sse(lines: Iterator) -> Iterator[Dict[str, Any]]: - """Parse SSE wire format into event dicts. - - Comment lines (heartbeats) yield a ``{"_heartbeat": True}`` marker - so callers can implement timeout logic even when no real events - are being emitted. - """ - event_type: Optional[str] = None - event_id: Optional[str] = None - data_lines: List[str] = [] - - for raw_line in lines: - line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line - - if line.startswith(":"): - yield {"_heartbeat": True} - continue # Comment (heartbeat) - if line == "": - # End of event - if data_lines: - data_str = "\n".join(data_lines) - try: - data = json.loads(data_str) - except (json.JSONDecodeError, ValueError): - data = {"content": data_str} - yield { - "event": event_type, - "id": event_id, - "data": data, - } - event_type = None - event_id = None - data_lines = [] - continue - - if line.startswith("event:"): - event_type = line[6:].strip() - elif line.startswith("id:"): - event_id = line[3:].strip() - elif line.startswith("data:"): - data_lines.append(line[5:].strip()) + # Transport, auth, reconnect and heartbeat detection live in the agent + # client; the runtime only maps raw events to AgentEvent and stops on + # DONE/ERROR. + for sse_event in self._agent_client.stream_sse(execution_id): + agent_event = self._sse_to_agent_event(sse_event, execution_id) + if agent_event is not None: + yield agent_event + if agent_event.type in (EventType.DONE, EventType.ERROR): + return @staticmethod def _sse_to_agent_event(sse_event: Dict[str, Any], execution_id: str) -> Optional[AgentEvent]: @@ -3661,6 +3503,7 @@ def start( session_id: Optional[str] = None, idempotency_key: Optional[str] = None, context: Optional[Dict[str, Any]] = None, + run_settings: Optional[Union[RunSettings, dict]] = None, **kwargs: Any, ) -> AgentHandle: """Start an agent asynchronously and return a handle. @@ -3730,6 +3573,7 @@ def start( timeout=effective_timeout, context=context, run_id=run_id, + run_settings=run_settings, ) worker_domain = self._resolve_worker_domain(execution_id, run_id) @@ -4033,6 +3877,7 @@ async def run_async( timeout: Optional[int] = None, credentials: Optional[List[str]] = None, context: Optional[Dict[str, Any]] = None, + run_settings: Optional[Union[RunSettings, dict]] = None, **kwargs: Any, ) -> AgentResult: """Execute an agent asynchronously (async-first implementation). @@ -4130,6 +3975,7 @@ async def run_async( credentials=credentials, context=context, run_id=run_id, + run_settings=run_settings, ) worker_domain = self._resolve_worker_domain(execution_id, run_id) @@ -4206,6 +4052,7 @@ async def start_async( session_id: Optional[str] = None, idempotency_key: Optional[str] = None, context: Optional[Dict[str, Any]] = None, + run_settings: Optional[Union[RunSettings, dict]] = None, **kwargs: Any, ) -> AgentHandle: """Start an agent asynchronously and return a handle (async version). @@ -4268,6 +4115,7 @@ async def start_async( timeout=effective_timeout, context=context, run_id=run_id, + run_settings=run_settings, ) worker_domain = self._resolve_worker_domain(execution_id, run_id) @@ -4341,7 +4189,7 @@ async def _stream_workflow_async(self, execution_id: str) -> AsyncIterator[Agent async def _stream_sse_async(self, execution_id: str) -> AsyncIterator[AgentEvent]: """Async version of :meth:`_stream_sse`.""" - async for sse_event in self._http.stream_sse(execution_id): + async for sse_event in self._agent_client.stream_sse_async(execution_id): agent_event = self._sse_to_agent_event(sse_event, execution_id) if agent_event is not None: yield agent_event @@ -4718,8 +4566,8 @@ async def shutdown_async(self) -> None: if self._workers_started and self._worker_manager is not None: self._worker_manager.stop() self._workers_started = False - if self._http is not None: - await self._http.close() + if self._agent_client is not None: + await self._agent_client.close_async() self._is_shutdown = True # ── Status / interaction ──────────────────────────────────────── @@ -4735,15 +4583,7 @@ def get_status(self, execution_id: str) -> AgentStatus: Returns: An :class:`AgentStatus`. """ - import requests as req_lib - - url = self._agent_api_url(f"/{execution_id}/status") - resp = req_lib.get(url, headers=self._agent_api_headers(content_type=""), timeout=30) - try: - resp.raise_for_status() - except req_lib.exceptions.HTTPError as exc: - _raise_api_error(exc, url=url) - data = resp.json() + data = self._agent_client.get_status(execution_id) raw_status = data.get("status", "UNKNOWN") is_complete = data.get("isComplete", False) @@ -4777,15 +4617,8 @@ def respond(self, execution_id: str, output: Any) -> None: execution_id: The execution ID. output: Any JSON-serialisable value to pass as the task output. """ - import requests as req_lib - - url = self._agent_api_url(f"/{execution_id}/respond") body = output if isinstance(output, dict) else {"output": output} - resp = req_lib.post(url, json=body, headers=self._agent_api_headers(), timeout=30) - try: - resp.raise_for_status() - except req_lib.exceptions.HTTPError as exc: - _raise_api_error(exc, url=url) + self._agent_client.respond(execution_id, body) logger.info("Responded to execution %s", execution_id) def approve(self, execution_id: str) -> None: @@ -4921,14 +4754,7 @@ def stop(self, execution_id: str) -> None: Args: execution_id: The Conductor execution ID. """ - import requests as req_lib - - url = self._agent_api_url(f"/{execution_id}/stop") - resp = req_lib.post(url, headers=self._agent_api_headers(), timeout=30) - try: - resp.raise_for_status() - except req_lib.exceptions.HTTPError as exc: - _raise_api_error(exc, url=url) + self._agent_client.stop(execution_id) # Also unblock any blocking PULL_WORKFLOW_MESSAGES wait. try: @@ -4954,16 +4780,7 @@ def signal(self, execution_id: str, message: str) -> None: execution_id: The Conductor execution ID. message: The signal text. Empty string clears the signal. """ - import requests as req_lib - - url = self._agent_api_url(f"/{execution_id}/signal") - resp = req_lib.post( - url, json={"message": message}, headers=self._agent_api_headers(), timeout=30 - ) - try: - resp.raise_for_status() - except req_lib.exceptions.HTTPError as exc: - _raise_api_error(exc, url=url) + self._agent_client.signal(execution_id, message) async def resume_async( self, @@ -4999,7 +4816,7 @@ async def resume_async( async def get_status_async(self, execution_id: str) -> AgentStatus: """Async version of :meth:`get_status`.""" - data = await self._http.get_status(execution_id) + data = await self._agent_client.get_status_async(execution_id) raw_status = data.get("status", "UNKNOWN") is_complete = data.get("isComplete", False) @@ -5023,7 +4840,7 @@ async def get_status_async(self, execution_id: str) -> AgentStatus: async def respond_async(self, execution_id: str, output: Any) -> None: """Async version of :meth:`respond`.""" body = output if isinstance(output, dict) else {"output": output} - await self._http.respond(execution_id, body) + await self._agent_client.respond_async(execution_id, body) logger.info("Responded to execution %s (async)", execution_id) async def approve_async(self, execution_id: str) -> None: @@ -5063,7 +4880,7 @@ async def cancel_async(self, execution_id: str, reason: str = "") -> None: async def stop_async(self, execution_id: str) -> None: """Async version of :meth:`stop`.""" - await self._http.stop(execution_id) + await self._agent_client.stop_async(execution_id) # Also unblock any blocking PULL_WORKFLOW_MESSAGES wait. try: loop = asyncio.get_event_loop() @@ -5075,16 +4892,13 @@ async def stop_async(self, execution_id: str) -> None: async def signal_async(self, execution_id: str, message: str) -> None: """Async version of :meth:`signal`.""" - await self._http.signal(execution_id, message) + await self._agent_client.signal_async(execution_id, message) # ── Session continuity helpers ──────────────────────────────────── def _get_session_messages(self, session_id: str, agent_name: str) -> List[Dict[str, Any]]: """Fetch conversation messages from the most recent execution with this session_id.""" try: - import requests as req_lib - - url = self._agent_api_url("/executions") params = { "agentName": agent_name, "freeText": session_id, @@ -5092,17 +4906,7 @@ def _get_session_messages(self, session_id: str, agent_name: str) -> List[Dict[s "size": 5, "status": "COMPLETED", } - resp = req_lib.get( - url, - params=params, - headers=self._agent_api_headers(content_type=""), - timeout=10, - ) - try: - resp.raise_for_status() - except req_lib.exceptions.HTTPError as exc: - _raise_api_error(exc, url=url) - executions = resp.json().get("results", []) + executions = self._agent_client.list_executions(params).get("results", []) for execution in executions: exec_id = execution.get("executionId") @@ -5433,13 +5237,8 @@ def _extract_tool_calls(self, workflow_run: Any) -> List[Dict[str, Any]]: def _fetch_agent_workflow(self, execution_id: str) -> Optional[dict]: """Fetch an execution with its full task list from GET /api/agent/execution/{id}.""" - import requests - try: - url = self._agent_api_url(f"/execution/{execution_id}") - resp = requests.get(url, headers=self._agent_api_headers(), timeout=10) - resp.raise_for_status() - return resp.json() + return self._agent_client.get_execution(execution_id) except Exception: return None diff --git a/src/conductor/ai/agents/runtime/server.py b/src/conductor/ai/agents/runtime/server.py deleted file mode 100644 index a97f1ce4..00000000 --- a/src/conductor/ai/agents/runtime/server.py +++ /dev/null @@ -1,138 +0,0 @@ -# Copyright (c) 2025 Agentspan -# Licensed under the MIT License. See LICENSE file in the project root for details. - -"""Server auto-start — detect and launch the Agentspan runtime server. - -Called during :class:`AgentRuntime` initialisation when the target server -URL points to localhost and is not yet responding. -""" - -from __future__ import annotations - -import os -import shutil -import subprocess -import sys -import time -from urllib.parse import urlparse - -import httpx - - -def _log(msg: str) -> None: - """Print a visible status message to stderr.""" - print(f"[agentspan] {msg}", file=sys.stderr, flush=True) - - -def _is_localhost(server_url: str) -> bool: - """Return ``True`` if *server_url* points to a loopback address.""" - host = (urlparse(server_url).hostname or "").lower() - return host in ("localhost", "127.0.0.1", "::1", "0.0.0.0") - - -def _is_server_ready(server_url: str, timeout: float = 2.0) -> bool: - """Return ``True`` if the server responds to a health check.""" - try: - base = server_url.rstrip("/") - # Strip /api suffix if present for the health endpoint - if base.endswith("/api"): - base = base[: -len("/api")] - resp = httpx.get(f"{base}/health", timeout=timeout) - return resp.status_code < 500 - except (httpx.ConnectError, httpx.ReadError, httpx.TimeoutException, OSError): - return False - - -def _find_or_install_cli() -> str | None: - """Locate the ``agentspan`` CLI binary, installing it if necessary.""" - # 1. Already on $PATH (system install, Homebrew, npm, etc.) - path = shutil.which("agentspan") - if path is not None: - return path - - # 2. Cached binary from a previous download - try: - from conductor.ai.cli import _binary_path - - candidate = _binary_path() - if os.path.isfile(candidate): - return candidate - except Exception: - pass - - # 3. Not found anywhere — download it now - try: - from conductor.ai.cli import _ensure_binary - - _log("Agentspan CLI not found. Installing...") - binary = _ensure_binary() - _log(f"Agentspan CLI installed at {binary}") - return binary - except Exception as exc: - _log(f"Failed to install Agentspan CLI: {exc}") - return None - - -def ensure_server_running(server_url: str, *, max_wait: float = 60.0) -> None: - """Start the Agentspan server if it is not already running. - - Only attempts to start the server when *server_url* points to localhost. - If the CLI binary cannot be found or installed, a warning is printed but - no exception is raised — the caller can still proceed (and will fail - later with a connection error). - - Raises: - RuntimeError: If the server does not become ready within *max_wait* - seconds after the start command is issued. - """ - if not server_url: - return - if not _is_localhost(server_url): - return - if _is_server_ready(server_url): - return - - _log(f"Agentspan server is not running at {server_url}.") - - cli = _find_or_install_cli() - if cli is None: - _log( - "Could not find or install the Agentspan CLI. " - "Please start the server manually with: agentspan server start" - ) - return - - _log("Starting Agentspan server...") - - try: - result = subprocess.run( - [cli, "server", "start"], - capture_output=True, - text=True, - ) - if result.returncode != 0: - error_msg = (result.stderr or result.stdout or "").strip() - _log(f"Failed to start Agentspan server: {error_msg}") - if "java" in error_msg.lower() or "jdk" in error_msg.lower(): - _log("The Agentspan server requires Java 21+. Install: https://adoptium.net/") - _log("Run 'agentspan doctor' for full diagnostics.") - return - except OSError as exc: - _log(f"Failed to start Agentspan server: {exc}") - return - - # Poll until the server is ready. - _log("Waiting for server to be ready...") - deadline = time.monotonic() + max_wait - while time.monotonic() < deadline: - if _is_server_ready(server_url): - _log("Agentspan server is ready.") - return - time.sleep(1.0) - - raise RuntimeError( - f"Agentspan server did not become ready at {server_url} " - f"within {max_wait:.0f} seconds. " - f"Check 'agentspan server logs' for details, " - f"or run 'agentspan doctor' for full diagnostics." - ) diff --git a/src/conductor/ai/agents/runtime/tool_registry.py b/src/conductor/ai/agents/runtime/tool_registry.py index a882b8b9..0ae4e418 100644 --- a/src/conductor/ai/agents/runtime/tool_registry.py +++ b/src/conductor/ai/agents/runtime/tool_registry.py @@ -42,7 +42,7 @@ def register_tool_workers( ``_tool_type_registry`` for HTTP/MCP tools and ``_tool_approval_flags`` for tools that require human approval. """ - from conductor.ai.agents.runtime.runtime import _default_task_def + from conductor.ai.agents.runtime.runtime import _credential_names, _default_task_def from conductor.ai.agents.tool import get_tool_defs from conductor.client.worker.worker_task import worker_task @@ -83,6 +83,7 @@ def register_tool_workers( retry_count=td.retry_count, retry_delay_seconds=td.retry_delay_seconds, retry_policy=td.retry_policy, + runtime_metadata=_credential_names(td.credentials), ), register_task_def=True, overwrite_task_def=True, diff --git a/src/conductor/client/agent_client.py b/src/conductor/client/agent_client.py new file mode 100644 index 00000000..ac19ac95 --- /dev/null +++ b/src/conductor/client/agent_client.py @@ -0,0 +1,150 @@ +# Copyright (c) 2025 Agentspan +# Licensed under the MIT License. See LICENSE file in the project root for details. + +"""Abstract interface for the Agent Runtime API (``/agent/*`` control plane). + +Follows the same interface + Orkes-implementation convention as +:class:`conductor.client.workflow_client.WorkflowClient` / +:class:`conductor.client.orkes.orkes_workflow_client.OrkesWorkflowClient`. The +concrete :class:`conductor.client.orkes.orkes_agent_client.OrkesAgentClient` is +built on the shared :class:`~conductor.client.http.api_client.ApiClient` (sync) +and :class:`~conductor.client.http.async_api_client.AsyncApiClient` (async), so +JWT minting / ``X-Authorization`` / TTL refresh / 401-retry are reused rather +than re-implemented. + +This interface is transport only — no dependency on the agent-DX layer +(``conductor.ai.agents``). +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, AsyncIterator, Dict, Iterator, Optional + + +class SSEUnavailableError(Exception): + """Raised when the server doesn't support SSE streaming.""" + + +class AgentClient(ABC): + """Client for the Agent Runtime API (``/agent/*``). + + Each control-plane operation has a synchronous method and an ``*_async`` + counterpart. + """ + + # ── start / deploy / compile ───────────────────────────────────────── + @abstractmethod + def start_agent(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """POST /agent/start — start an agent execution.""" + pass + + @abstractmethod + async def start_agent_async(self, payload: Dict[str, Any]) -> Dict[str, Any]: + pass + + @abstractmethod + def deploy_agent(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """POST /agent/deploy — deploy agent (compile + register, no execution).""" + pass + + @abstractmethod + async def deploy_agent_async(self, payload: Dict[str, Any]) -> Dict[str, Any]: + pass + + @abstractmethod + def compile_agent(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """POST /agent/compile — compile agent config to an agent/workflow def.""" + pass + + @abstractmethod + async def compile_agent_async(self, payload: Dict[str, Any]) -> Dict[str, Any]: + pass + + # ── status / execution ─────────────────────────────────────────────── + @abstractmethod + def get_status(self, execution_id: str) -> Dict[str, Any]: + """GET /agent/{id}/status — fetch execution status.""" + pass + + @abstractmethod + async def get_status_async(self, execution_id: str) -> Dict[str, Any]: + pass + + @abstractmethod + def get_execution(self, execution_id: str) -> Dict[str, Any]: + """GET /agent/execution/{id} — full execution tree.""" + pass + + @abstractmethod + async def get_execution_async(self, execution_id: str) -> Dict[str, Any]: + pass + + @abstractmethod + def list_executions(self, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """GET /agent/executions — search executions (query ``params``).""" + pass + + @abstractmethod + async def list_executions_async( + self, params: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + pass + + # ── HITL / control ─────────────────────────────────────────────────── + @abstractmethod + def respond(self, execution_id: str, body: Dict[str, Any]) -> None: + """POST /agent/{id}/respond — complete a pending human task.""" + pass + + @abstractmethod + async def respond_async(self, execution_id: str, body: Dict[str, Any]) -> None: + pass + + @abstractmethod + def stop(self, execution_id: str) -> None: + """POST /agent/{id}/stop — graceful deterministic stop.""" + pass + + @abstractmethod + async def stop_async(self, execution_id: str) -> None: + pass + + @abstractmethod + def signal(self, execution_id: str, message: str) -> None: + """POST /agent/{id}/signal — inject persistent context.""" + pass + + @abstractmethod + async def signal_async(self, execution_id: str, message: str) -> None: + pass + + # ── streaming ──────────────────────────────────────────────────────── + @abstractmethod + def stream_sse( + self, execution_id: str, last_event_id: Optional[str] = None + ) -> Iterator[Dict[str, Any]]: + """GET /agent/stream/{id} — consume SSE events (sync generator). + + Yields parsed event dicts. Auto-reconnects with ``Last-Event-ID`` on + connection drops. Raises :class:`SSEUnavailableError` if the server + doesn't support SSE or sends only heartbeats. + """ + pass + + @abstractmethod + def stream_sse_async( + self, execution_id: str, last_event_id: Optional[str] = None + ) -> AsyncIterator[Dict[str, Any]]: + """Async counterpart of :meth:`stream_sse`.""" + pass + + # ── lifecycle ──────────────────────────────────────────────────────── + @abstractmethod + def close(self) -> None: + """Release any transport resources held by the client.""" + pass + + @abstractmethod + async def close_async(self) -> None: + pass diff --git a/src/conductor/client/ai/__init__.py b/src/conductor/client/ai/__init__.py index 4635d6d8..b917b4b8 100644 --- a/src/conductor/client/ai/__init__.py +++ b/src/conductor/client/ai/__init__.py @@ -1,19 +1,21 @@ # Copyright (c) 2025 Agentspan # Licensed under the MIT License. See LICENSE file in the project root for details. -"""Agent-facing clients and models for the Conductor SDK. +"""Agent-facing models and exceptions for the Conductor SDK. -Canonical home of the ``/agent/*`` transport (:class:`AgentApiClient`), the -agent schedule models (:class:`Schedule`/:class:`ScheduleInfo`), and the agent -exception hierarchy. Build the transport via ``OrkesClients.get_agent_client()``; -the schedule lifecycle lives on ``OrkesClients.get_scheduler_client()``. +Home of the agent schedule models (:class:`Schedule`/:class:`ScheduleInfo`) and +the agent exception hierarchy. The ``/agent/*`` client is +:class:`conductor.client.agent_client.AgentClient` (interface) implemented by +:class:`conductor.client.orkes.orkes_agent_client.OrkesAgentClient`; build it via +``OrkesClients.get_agent_client()``. The schedule lifecycle lives on +``OrkesClients.get_scheduler_client()``. Everything here must stay importable without the ``[agents]`` extra and must not import ``conductor.ai`` (the agents authoring layer composes these clients, never the other way around). """ -from conductor.client.ai.agent_api_client import AgentApiClient, SSEUnavailableError +from conductor.client.agent_client import AgentClient, SSEUnavailableError from conductor.client.ai.agent_errors import ( AgentAPIError, AgentNotFoundError, @@ -28,7 +30,7 @@ ) __all__ = [ - "AgentApiClient", + "AgentClient", "AgentAPIError", "AgentNotFoundError", "AgentspanError", diff --git a/src/conductor/client/ai/agent_api_client.py b/src/conductor/client/ai/agent_api_client.py deleted file mode 100644 index 6c59ecc3..00000000 --- a/src/conductor/client/ai/agent_api_client.py +++ /dev/null @@ -1,358 +0,0 @@ -# Copyright (c) 2025 Agentspan -# Licensed under the MIT License. See LICENSE file in the project root for details. - -"""Async HTTP client for the Agent Runtime API (``/agent/*`` control plane). - -Centralizes all ``httpx`` usage for the agent API endpoints: -- POST /agent/start -- POST /agent/deploy -- POST /agent/compile -- GET /agent/{id}/status -- POST /agent/{id}/respond -- POST /agent/{id}/stop -- POST /agent/{id}/signal -- GET /agent/stream/{id} (SSE) -- GET /agent/execution/{id} (sync; token-usage lookups) - -Configuration-first: the host and mint credentials come from a conductor-python -:class:`Configuration`; an explicit ``token`` is sent as-is and never re-minted. -This class is transport only — the agent-DX surface (``run``/``start``/``deploy`` -sugar, ``AgentHandle`` plumbing) lives in -``conductor.ai.agents.runtime.http_client.AgentClient``, which composes it. -""" - -from __future__ import annotations - -import json -import logging -import time -from typing import Any, AsyncIterator, Dict, List, Optional - -import httpx - -from conductor.client.ai.agent_errors import _raise_api_error -from conductor.client.configuration.configuration import Configuration - -# Logger name kept under the conductor.ai hierarchy: AgentRuntime applies the -# user-configured log level to logging.getLogger("conductor.ai"), and the -# transport must keep honoring it. -logger = logging.getLogger("conductor.ai.agents.runtime.http_client") - -_SSE_NO_EVENT_TIMEOUT = 15 # seconds to wait for first real event before fallback - - -class SSEUnavailableError(Exception): - """Raised when the server doesn't support SSE streaming.""" - - -class AgentApiClient: - """Async HTTP transport for the ``/agent/*`` control-plane endpoints. - - Auth resolution for the ``X-Authorization`` header: - - 1. an explicit ``token`` passed to the constructor — sent as-is; - 2. otherwise a JWT minted via ``POST {host}/token`` from the - ``Configuration``'s ``authentication_settings`` (requires both key id - and secret), stored via ``Configuration.update_token()`` and reused - until the configuration's token TTL elapses — the same cache every - generated client built from that ``Configuration`` shares; - 3. no credentials → no header (anonymous / security-disabled servers). - """ - - def __init__( - self, configuration: Optional[Configuration] = None, *, token: Optional[str] = None - ) -> None: - if configuration is None: - configuration = Configuration() - self._configuration = configuration - self._server_url = (configuration.host or "").rstrip("/") - self._token_static = token or "" - self._client: Optional[httpx.AsyncClient] = None - - def _mint_credentials(self) -> Optional[tuple]: - """(key_id, key_secret) when the configuration can mint, else None.""" - settings = self._configuration.authentication_settings - if settings is None or not settings.key_id or not settings.key_secret: - return None - return settings.key_id, settings.key_secret - - def _cached_token_header(self) -> Optional[Dict[str, str]]: - # Same TTL rule as the generated ApiClient's auth headers: a token - # stored on the Configuration is reused until auth_token_ttl_msec - # elapses, then re-minted. - token = self._configuration.AUTH_TOKEN - if not token: - return None - now = round(time.time() * 1000) - if now - self._configuration.token_update_time > self._configuration.auth_token_ttl_msec: - return None - return {"X-Authorization": token} - - async def _auth_headers(self) -> Dict[str, str]: - """``X-Authorization`` header for secured hosts (orkes); {} when anonymous.""" - if self._token_static: - return {"X-Authorization": self._token_static} - creds = self._mint_credentials() - if creds is None: - return {} - cached = self._cached_token_header() - if cached is not None: - return cached - - try: - client = await self._get_client() - resp = await client.post( - f"{self._server_url}/token", - json={"keyId": creds[0], "keySecret": creds[1]}, - ) - resp.raise_for_status() - token = resp.json().get("token") or "" - except Exception as e: # pragma: no cover - network/credential failures - logger.warning("Failed to mint agent API token: %s", e) - return {} - if not token: - return {} - self._configuration.update_token(token) - return {"X-Authorization": token} - - def _sync_headers(self) -> Dict[str, str]: - """``X-Authorization`` header for synchronous ``requests`` calls.""" - if self._token_static: - return {"X-Authorization": self._token_static} - creds = self._mint_credentials() - if creds is None: - return {} - cached = self._cached_token_header() - if cached is not None: - return cached - - import requests - - try: - resp = requests.post( - f"{self._server_url}/token", - json={"keyId": creds[0], "keySecret": creds[1]}, - timeout=30, - ) - resp.raise_for_status() - token = resp.json().get("token") or "" - except Exception as e: # pragma: no cover - network/credential failures - logger.warning("Failed to mint agent API token: %s", e) - return {} - if not token: - return {} - self._configuration.update_token(token) - return {"X-Authorization": token} - - async def _get_client(self) -> httpx.AsyncClient: - if self._client is None or self._client.is_closed: - self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=5.0)) - return self._client - - def _url(self, path: str) -> str: - return f"{self._server_url}/agent{path}" - - # ── Agent API endpoints ────────────────────────────────────────── - - async def start_agent(self, payload: Dict[str, Any]) -> Dict[str, Any]: - """POST /agent/start — start an agent execution.""" - client = await self._get_client() - url = self._url("/start") - resp = await client.post(url, json=payload, headers=await self._auth_headers()) - try: - resp.raise_for_status() - except httpx.HTTPStatusError as exc: - _raise_api_error(exc, url=url) - return resp.json() - - async def deploy_agent(self, payload: Dict[str, Any]) -> Dict[str, Any]: - """POST /agent/deploy — deploy agent (compile + register, no execution).""" - client = await self._get_client() - url = self._url("/deploy") - resp = await client.post(url, json=payload, headers=await self._auth_headers()) - try: - resp.raise_for_status() - except httpx.HTTPStatusError as exc: - _raise_api_error(exc, url=url) - return resp.json() - - async def compile_agent(self, config_json: Dict[str, Any]) -> Dict[str, Any]: - """POST /agent/compile — compile agent config to agent def.""" - client = await self._get_client() - url = self._url("/compile") - resp = await client.post(url, json=config_json, headers=await self._auth_headers()) - try: - resp.raise_for_status() - except httpx.HTTPStatusError as exc: - _raise_api_error(exc, url=url) - return resp.json() - - async def get_status(self, execution_id: str) -> Dict[str, Any]: - """GET /agent/{id}/status — fetch execution status.""" - client = await self._get_client() - url = self._url(f"/{execution_id}/status") - resp = await client.get(url, headers=await self._auth_headers()) - try: - resp.raise_for_status() - except httpx.HTTPStatusError as exc: - _raise_api_error(exc, url=url) - return resp.json() - - async def respond(self, execution_id: str, body: Dict[str, Any]) -> None: - """POST /agent/{id}/respond — complete a pending human task.""" - client = await self._get_client() - url = self._url(f"/{execution_id}/respond") - resp = await client.post(url, json=body, headers=await self._auth_headers()) - try: - resp.raise_for_status() - except httpx.HTTPStatusError as exc: - _raise_api_error(exc, url=url) - - async def stop(self, execution_id: str) -> None: - """POST /agent/{id}/stop — graceful deterministic stop.""" - client = await self._get_client() - url = self._url(f"/{execution_id}/stop") - resp = await client.post(url, headers=await self._auth_headers()) - try: - resp.raise_for_status() - except httpx.HTTPStatusError as exc: - _raise_api_error(exc, url=url) - - async def signal(self, execution_id: str, message: str) -> None: - """POST /agent/{id}/signal — inject persistent context.""" - client = await self._get_client() - url = self._url(f"/{execution_id}/signal") - resp = await client.post(url, json={"message": message}, headers=await self._auth_headers()) - try: - resp.raise_for_status() - except httpx.HTTPStatusError as exc: - _raise_api_error(exc, url=url) - - def get_execution_sync(self, execution_id: str) -> Dict[str, Any]: - """GET /agent/execution/{id} — full execution tree (synchronous).""" - import requests - - url = f"{self._server_url}/agent/execution/{execution_id}" - resp = requests.get(url, headers=self._sync_headers(), timeout=10) - resp.raise_for_status() - return resp.json() - - async def stream_sse(self, execution_id: str) -> AsyncIterator[Dict[str, Any]]: - """GET /agent/stream/{id} — consume SSE events. - - Yields parsed event dicts. Auto-reconnects with ``Last-Event-ID`` - on connection drops. Raises :class:`SSEUnavailableError` if the - server doesn't support SSE or sends only heartbeats. - """ - url = f"{self._server_url}/agent/stream/{execution_id}" - headers = {**(await self._auth_headers()), "Accept": "text/event-stream"} - - last_event_id: Optional[str] = None - first_connect = True - got_real_event = False - - while True: - try: - req_headers = dict(headers) - if last_event_id is not None: - req_headers["Last-Event-ID"] = last_event_id - - client = await self._get_client() - async with client.stream( - "GET", - url, - headers=req_headers, - timeout=httpx.Timeout(30.0, connect=5.0), - ) as resp: - if resp.status_code != 200: - if first_connect: - raise SSEUnavailableError(f"Server returned {resp.status_code}") - logger.warning( - "SSE reconnect failed (status=%s), stopping stream", - resp.status_code, - ) - return - - first_connect = False - connect_time = time.monotonic() - - async for sse_event in self._parse_sse_async(resp.aiter_lines()): - if sse_event.get("_heartbeat"): - if ( - not got_real_event - and time.monotonic() - connect_time > _SSE_NO_EVENT_TIMEOUT - ): - raise SSEUnavailableError( - "SSE connected but no events received " - f"(only heartbeats for {_SSE_NO_EVENT_TIMEOUT}s)" - ) - continue - - if sse_event.get("id"): - last_event_id = sse_event["id"] - - got_real_event = True - yield sse_event - - # Stream ended cleanly - return - - except SSEUnavailableError: - raise - except Exception as e: - if first_connect: - raise SSEUnavailableError(str(e)) - logger.warning("SSE connection lost (%s), reconnecting in 1s...", e) - import asyncio - - await asyncio.sleep(1) - - @staticmethod - async def _parse_sse_async( - lines: AsyncIterator[str], - ) -> AsyncIterator[Dict[str, Any]]: - """Parse SSE wire format into event dicts (async version). - - Comment lines (heartbeats) yield ``{"_heartbeat": True}``. - """ - event_type: Optional[str] = None - event_id: Optional[str] = None - data_lines: List[str] = [] - - async for raw_line in lines: - line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line - - if line.startswith(":"): - yield {"_heartbeat": True} - continue - if line == "": - if data_lines: - data_str = "\n".join(data_lines) - try: - data = json.loads(data_str) - except (json.JSONDecodeError, ValueError): - data = {"content": data_str} - yield { - "event": event_type, - "id": event_id, - "data": data, - } - event_type = None - event_id = None - data_lines = [] - continue - - if line.startswith("event:"): - event_type = line[6:].strip() - elif line.startswith("id:"): - event_id = line[3:].strip() - elif line.startswith("data:"): - data_lines.append(line[5:].strip()) - - # ── Lifecycle ──────────────────────────────────────────────────── - - async def close(self) -> None: - """Close the underlying httpx client.""" - if self._client is not None and not self._client.is_closed: - await self._client.aclose() - self._client = None diff --git a/src/conductor/client/configuration/configuration.py b/src/conductor/client/configuration/configuration.py index c75877fa..f42880f2 100644 --- a/src/conductor/client/configuration/configuration.py +++ b/src/conductor/client/configuration/configuration.py @@ -31,14 +31,15 @@ def __init__( authentication_settings: AuthenticationSettings = None, server_api_url: Optional[str] = None, auth_token_ttl_min: int = 45, - register_schema: Optional[bool] = None + register_schema: Optional[bool] = None, + log_level=None ): if server_api_url is not None: self.host = server_api_url elif base_url is not None: self.host = base_url + "/api" else: - self.host = os.getenv("CONDUCTOR_SERVER_URL") + self.host = os.getenv("CONDUCTOR_SERVER_URL") or os.getenv("AGENTSPAN_SERVER_URL") if self.host is None or self.host == "": self.host = "http://localhost:8080/api" @@ -59,8 +60,23 @@ def __init__( self.authentication_settings = None - # Debug switch + # Debug switch (sets __log_level to DEBUG/INFO) self.debug = debug + # Explicit SDK-wide log level: a level name ("INFO", "DEBUG", …) or an + # int. Overrides the debug-derived default; falls back to the + # CONDUCTOR_LOG_LEVEL / AGENTSPAN_LOG_LEVEL env vars. Applies to every + # SDK component (clients, workers, agent runtime). + resolved_level = ( + log_level + if log_level is not None + else (os.getenv("CONDUCTOR_LOG_LEVEL") or os.getenv("AGENTSPAN_LOG_LEVEL")) + ) + if resolved_level is not None and str(resolved_level).strip() != "": + self.__log_level = ( + resolved_level + if isinstance(resolved_level, int) + else getattr(logging, str(resolved_level).upper(), self.__log_level) + ) # Log format self.logger_format = "%(asctime)s %(name)-12s %(levelname)-8s %(message)s" diff --git a/src/conductor/client/http/api_client.py b/src/conductor/client/http/api_client.py index bcf7c31b..c753da00 100644 --- a/src/conductor/client/http/api_client.py +++ b/src/conductor/client/http/api_client.py @@ -776,6 +776,13 @@ def __deserialize_model(self, data, klass): return instance def get_authentication_headers(self): + """Public accessor for the current auth header (TTL-aware). + + Returns ``{'header': {'X-Authorization': }}`` or ``None`` when the + server is anonymous / no token is available. Used by transports (e.g. SSE + streaming) that bypass :meth:`call_api` but must reuse the same token + management rather than minting separately. + """ return self.__get_authentication_headers() def __get_authentication_headers(self): diff --git a/src/conductor/client/http/models/task.py b/src/conductor/client/http/models/task.py index fc0dce3e..fe80fd6c 100644 --- a/src/conductor/client/http/models/task.py +++ b/src/conductor/client/http/models/task.py @@ -63,7 +63,8 @@ class Task: _subworkflow_changed: bool = field(default=None) _parent_task_id: str = field(default=None) _first_start_time: int = field(default=None) - + _runtime_metadata: Dict[str, str] = field(default=None) + # Fields that are in Python but not in Java _loop_over_task: bool = field(default=None) _task_definition: Any = field(default=None) @@ -110,6 +111,7 @@ class Task: 'subworkflow_changed': 'bool', 'parent_task_id': 'str', 'first_start_time': 'int', + 'runtime_metadata': 'dict(str, str)', 'loop_over_task': 'bool', 'task_definition': 'TaskDef', 'queue_wait_time': 'int' @@ -156,6 +158,7 @@ class Task: 'subworkflow_changed': 'subworkflowChanged', 'parent_task_id': 'parentTaskId', 'first_start_time': 'firstStartTime', + 'runtime_metadata': 'runtimeMetadata', 'loop_over_task': 'loopOverTask', 'task_definition': 'taskDefinition', 'queue_wait_time': 'queueWaitTime' @@ -171,7 +174,8 @@ def __init__(self, task_type=None, status=None, input_data=None, reference_task_ external_input_payload_storage_path=None, external_output_payload_storage_path=None, workflow_priority=None, execution_name_space=None, isolation_group_id=None, iteration=None, sub_workflow_id=None, subworkflow_changed=None, loop_over_task=None, task_definition=None, - queue_wait_time=None, parent_task_id=None, first_start_time=None): # noqa: E501 + queue_wait_time=None, parent_task_id=None, first_start_time=None, + runtime_metadata=None): # noqa: E501 """Task - a model defined in Swagger""" # noqa: E501 self._task_type = None self._status = None @@ -216,6 +220,7 @@ def __init__(self, task_type=None, status=None, input_data=None, reference_task_ self._loop_over_task = None self._task_definition = None self._queue_wait_time = None + self._runtime_metadata = None self.discriminator = None if task_type is not None: self.task_type = task_type @@ -303,6 +308,8 @@ def __init__(self, task_type=None, status=None, input_data=None, reference_task_ self.task_definition = task_definition if queue_wait_time is not None: self.queue_wait_time = queue_wait_time + if runtime_metadata is not None: + self.runtime_metadata = runtime_metadata def __post_init__(self): """Post initialization for dataclass""" @@ -1192,6 +1199,30 @@ def queue_wait_time(self, queue_wait_time): self._queue_wait_time = queue_wait_time + @property + def runtime_metadata(self): + """Gets the runtime_metadata of this Task. # noqa: E501 + + Secret values the server resolved for this task at poll time and delivered on the wire only + (never persisted). Populated when the task's TaskDef.runtimeMetadata declares secret names the + host resolves from its secret store (conductor-oss PR #1255). Empty/absent otherwise. + + :return: The runtime_metadata of this Task. # noqa: E501 + :rtype: dict(str, str) + """ + return self._runtime_metadata + + @runtime_metadata.setter + def runtime_metadata(self, runtime_metadata): + """Sets the runtime_metadata of this Task. + + + :param runtime_metadata: The runtime_metadata of this Task. # noqa: E501 + :type: dict(str, str) + """ + + self._runtime_metadata = runtime_metadata + def to_dict(self): """Returns the model properties as a dict""" result = {} diff --git a/src/conductor/client/http/models/task_def.py b/src/conductor/client/http/models/task_def.py index 7d486862..bf2f48b8 100644 --- a/src/conductor/client/http/models/task_def.py +++ b/src/conductor/client/http/models/task_def.py @@ -50,7 +50,8 @@ class TaskDef: 'output_schema': 'SchemaDef', 'enforce_schema': 'bool', 'base_type': 'str', - 'total_timeout_seconds': 'int' + 'total_timeout_seconds': 'int', + 'runtime_metadata': 'list[str]' } attribute_map = { @@ -82,7 +83,8 @@ class TaskDef: 'output_schema': 'outputSchema', 'enforce_schema': 'enforceSchema', 'base_type': 'baseType', - 'total_timeout_seconds': 'totalTimeoutSeconds' + 'total_timeout_seconds': 'totalTimeoutSeconds', + 'runtime_metadata': 'runtimeMetadata' } # Fields for @dataclass @@ -115,7 +117,8 @@ class TaskDef: _enforce_schema: bool = field(default=False, init=False) _base_type: Optional[str] = field(default=None, init=False) _total_timeout_seconds: Optional[int] = field(default=None, init=False) - + _runtime_metadata: Optional[List[str]] = field(default=None, init=False) + # InitVars for constructor parameters owner_app: InitVar[Optional[str]] = None create_time: InitVar[Optional[int]] = None @@ -146,7 +149,8 @@ class TaskDef: enforce_schema: InitVar[bool] = False base_type: InitVar[Optional[str]] = None total_timeout_seconds: InitVar[Optional[int]] = None - + runtime_metadata: InitVar[Optional[List[str]]] = None + discriminator: Optional[str] = field(default=None, init=False) def __init__(self, owner_app=None, create_time=None, update_time=None, created_by=None, updated_by=None, name=None, @@ -156,7 +160,7 @@ def __init__(self, owner_app=None, create_time=None, update_time=None, created_b rate_limit_frequency_in_seconds=None, isolation_group_id=None, execution_name_space=None, owner_email=None, poll_timeout_seconds=None, backoff_scale_factor=None, input_schema : SchemaDef = None, output_schema : SchemaDef = None, enforce_schema : bool = False, - base_type=None, total_timeout_seconds=None): # noqa: E501 + base_type=None, total_timeout_seconds=None, runtime_metadata=None): # noqa: E501 """TaskDef - a model defined in Swagger""" # noqa: E501 self._owner_app = None self._create_time = None @@ -184,6 +188,7 @@ def __init__(self, owner_app=None, create_time=None, update_time=None, created_b self._backoff_scale_factor = None self._base_type = None self._total_timeout_seconds = None + self._runtime_metadata = None self.discriminator = None if owner_app is not None: self.owner_app = owner_app @@ -238,13 +243,16 @@ def __init__(self, owner_app=None, create_time=None, update_time=None, created_b self.base_type = base_type if total_timeout_seconds is not None: self.total_timeout_seconds = total_timeout_seconds + if runtime_metadata is not None: + self.runtime_metadata = runtime_metadata - def __post_init__(self, owner_app, create_time, update_time, created_by, updated_by, name, description, + def __post_init__(self, owner_app, create_time, update_time, created_by, updated_by, name, description, retry_count, timeout_seconds, input_keys, output_keys, timeout_policy, retry_logic, retry_delay_seconds, response_timeout_seconds, concurrent_exec_limit, input_template, rate_limit_per_frequency, rate_limit_frequency_in_seconds, isolation_group_id, execution_name_space, owner_email, poll_timeout_seconds, backoff_scale_factor, - input_schema, output_schema, enforce_schema, base_type, total_timeout_seconds): + input_schema, output_schema, enforce_schema, base_type, total_timeout_seconds, + runtime_metadata=None): if owner_app is not None: self.owner_app = owner_app if create_time is not None: @@ -303,6 +311,8 @@ def __post_init__(self, owner_app, create_time, update_time, created_by, updated self.base_type = base_type if total_timeout_seconds is not None: self.total_timeout_seconds = total_timeout_seconds + if runtime_metadata is not None: + self.runtime_metadata = runtime_metadata @property @deprecated @@ -906,6 +916,29 @@ def total_timeout_seconds(self, total_timeout_seconds: int): """ self._total_timeout_seconds = total_timeout_seconds + @property + def runtime_metadata(self) -> List[str]: + """Gets the runtime_metadata of this TaskDef. # noqa: E501 + + Names of secrets/environment variables the task needs resolved. The host resolves these at + the worker's poll time and delivers the values on the wire-only ``Task.runtimeMetadata`` + (conductor-oss PR #1255). This is the credential-delivery contract for workers. + + :return: The runtime_metadata of this TaskDef. # noqa: E501 + :rtype: list[str] + """ + return self._runtime_metadata + + @runtime_metadata.setter + def runtime_metadata(self, runtime_metadata: List[str]): + """Sets the runtime_metadata of this TaskDef. + + + :param runtime_metadata: The runtime_metadata of this TaskDef. # noqa: E501 + :type: list[str] + """ + self._runtime_metadata = runtime_metadata + def to_dict(self): """Returns the model properties as a dict""" result = {} diff --git a/src/conductor/client/orkes/orkes_agent_client.py b/src/conductor/client/orkes/orkes_agent_client.py new file mode 100644 index 00000000..7c360bd8 --- /dev/null +++ b/src/conductor/client/orkes/orkes_agent_client.py @@ -0,0 +1,367 @@ +# Copyright (c) 2025 Agentspan +# Licensed under the MIT License. See LICENSE file in the project root for details. + +"""Orkes implementation of :class:`AgentClient` for the ``/agent/*`` control plane. + +All wire calls go through the shared :class:`ApiClient` (sync) / +:class:`AsyncApiClient` (async) built from the :class:`Configuration`, so JWT +minting, ``X-Authorization`` injection, TTL refresh and 401-retry are reused — +never re-implemented here. The one exception is SSE streaming, which the +generated ``call_api`` cannot do; it uses ``requests`` / ``httpx`` directly but +still borrows the auth header from the ``ApiClient``'s token machinery. +""" + +from __future__ import annotations + +import json +import logging +import time +from typing import Any, AsyncIterator, Dict, Iterator, List, Optional + +from conductor.client.agent_client import AgentClient, SSEUnavailableError +from conductor.client.ai.agent_errors import AgentAPIError, AgentNotFoundError +from conductor.client.configuration.configuration import Configuration +from conductor.client.http.async_api_client import AsyncApiClient +from conductor.client.http.rest import ApiException +from conductor.client.orkes.orkes_base_client import OrkesBaseClient + +logger = logging.getLogger( + Configuration.get_logging_formatted_name("conductor.client.orkes.orkes_agent_client") +) + +_SSE_NO_EVENT_TIMEOUT = 15 # seconds to wait for a real event before declaring SSE unavailable +_AUTH_SETTINGS = ["api_key"] + + +def _raise_from_api_exception(exc: ApiException, url: str = "") -> None: + """Map a conductor :class:`ApiException` to an agent-SDK exception.""" + status = getattr(exc, "status", 0) or 0 + body = getattr(exc, "body", None) or getattr(exc, "reason", None) or str(exc) + if status == 404: + raise AgentNotFoundError(status, body, url) from exc + raise AgentAPIError(status, body, url) from exc + + +class OrkesAgentClient(OrkesBaseClient, AgentClient): + def __init__(self, configuration: Configuration): + super(OrkesAgentClient, self).__init__(configuration) + self._configuration = configuration + self._server_url = (configuration.host or "").rstrip("/") + self._async_api_client: Optional[AsyncApiClient] = None + self._schedule_client_instance: Any = None + # Persistent httpx client for async SSE streaming (created lazily). + self._sse_async_client: Any = None + + # ── internal helpers ───────────────────────────────────────────────── + + def _async(self) -> AsyncApiClient: + if self._async_api_client is None: + self._async_api_client = AsyncApiClient(self._configuration) + return self._async_api_client + + def _json_headers(self) -> Dict[str, str]: + return { + "Accept": self.api_client.select_header_accept(["application/json"]), + "Content-Type": self.api_client.select_header_content_type(["application/json"]), + } + + def _agent_url(self, path: str) -> str: + return f"{self._server_url}/agent{path}" + + def _sync_auth_header(self) -> Dict[str, str]: + """Flat ``X-Authorization`` header reused from the sync ApiClient ({} if none).""" + headers = self.api_client.get_authentication_headers() + if not headers: + return {} + return dict(headers.get("header", {})) + + async def _async_auth_header(self) -> Dict[str, str]: + headers = await self._async().get_authentication_headers() + if not headers: + return {} + return dict(headers.get("header", {})) + + def _call(self, path: str, method: str, body=None, response_type=None, query_params=None): + try: + return self.api_client.call_api( + path, method, + {}, query_params or [], self._json_headers(), + body=body, + post_params=[], + files={}, + response_type=response_type, + auth_settings=_AUTH_SETTINGS, + _return_http_data_only=True, + _preload_content=True, + ) + except ApiException as exc: + _raise_from_api_exception(exc, url=self._agent_url(path[len("/agent"):])) + + async def _call_async(self, path: str, method: str, body=None, response_type=None, query_params=None): + try: + return await self._async().call_api( + path, method, + {}, query_params or [], self._json_headers(), + body=body, + post_params=[], + files={}, + response_type=response_type, + auth_settings=_AUTH_SETTINGS, + _return_http_data_only=True, + _preload_content=True, + ) + except ApiException as exc: + _raise_from_api_exception(exc, url=self._agent_url(path[len("/agent"):])) + + # ── start / deploy / compile ───────────────────────────────────────── + + def start_agent(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._call("/agent/start", "POST", body=payload, response_type="object") or {} + + async def start_agent_async(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return await self._call_async("/agent/start", "POST", body=payload, response_type="object") or {} + + def deploy_agent(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._call("/agent/deploy", "POST", body=payload, response_type="object") or {} + + async def deploy_agent_async(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return await self._call_async("/agent/deploy", "POST", body=payload, response_type="object") or {} + + def compile_agent(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._call("/agent/compile", "POST", body=payload, response_type="object") or {} + + async def compile_agent_async(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return await self._call_async("/agent/compile", "POST", body=payload, response_type="object") or {} + + # ── status / execution ─────────────────────────────────────────────── + + def get_status(self, execution_id: str) -> Dict[str, Any]: + return self._call(f"/agent/{execution_id}/status", "GET", response_type="object") or {} + + async def get_status_async(self, execution_id: str) -> Dict[str, Any]: + return await self._call_async(f"/agent/{execution_id}/status", "GET", response_type="object") or {} + + def get_execution(self, execution_id: str) -> Dict[str, Any]: + return self._call(f"/agent/execution/{execution_id}", "GET", response_type="object") or {} + + async def get_execution_async(self, execution_id: str) -> Dict[str, Any]: + return await self._call_async(f"/agent/execution/{execution_id}", "GET", response_type="object") or {} + + def list_executions(self, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + query = list((params or {}).items()) + return self._call("/agent/executions", "GET", response_type="object", query_params=query) or {} + + async def list_executions_async(self, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + query = list((params or {}).items()) + return await self._call_async( + "/agent/executions", "GET", response_type="object", query_params=query + ) or {} + + # ── HITL / control ─────────────────────────────────────────────────── + + def respond(self, execution_id: str, body: Dict[str, Any]) -> None: + self._call(f"/agent/{execution_id}/respond", "POST", body=body) + + async def respond_async(self, execution_id: str, body: Dict[str, Any]) -> None: + await self._call_async(f"/agent/{execution_id}/respond", "POST", body=body) + + def stop(self, execution_id: str) -> None: + self._call(f"/agent/{execution_id}/stop", "POST") + + async def stop_async(self, execution_id: str) -> None: + await self._call_async(f"/agent/{execution_id}/stop", "POST") + + def signal(self, execution_id: str, message: str) -> None: + self._call(f"/agent/{execution_id}/signal", "POST", body={"message": message}) + + async def signal_async(self, execution_id: str, message: str) -> None: + await self._call_async(f"/agent/{execution_id}/signal", "POST", body={"message": message}) + + # ── streaming (SSE — not call_api-able; reuses the ApiClient token) ─── + + def stream_sse( + self, execution_id: str, last_event_id: Optional[str] = None + ) -> Iterator[Dict[str, Any]]: + import requests + + url = self._agent_url(f"/stream/{execution_id}") + base_headers = {**self._sync_auth_header(), "Accept": "text/event-stream"} + first_connect = True + got_real_event = False + + while True: + try: + req_headers = dict(base_headers) + if last_event_id is not None: + req_headers["Last-Event-ID"] = last_event_id + with requests.get(url, headers=req_headers, stream=True, timeout=(5, 30)) as resp: + if resp.status_code != 200: + if first_connect: + raise SSEUnavailableError(f"Server returned {resp.status_code}") + logger.warning("SSE reconnect failed (status=%s), stopping", resp.status_code) + return + first_connect = False + connect_time = time.monotonic() + for sse_event in self._parse_sse(resp.iter_lines()): + if sse_event.get("_heartbeat"): + if (not got_real_event + and time.monotonic() - connect_time > _SSE_NO_EVENT_TIMEOUT): + raise SSEUnavailableError( + "SSE connected but no events received " + f"(only heartbeats for {_SSE_NO_EVENT_TIMEOUT}s)" + ) + continue + if sse_event.get("id"): + last_event_id = sse_event["id"] + got_real_event = True + yield sse_event + return + except SSEUnavailableError: + raise + except Exception as e: + if first_connect: + raise SSEUnavailableError(str(e)) + logger.warning("SSE connection lost (%s), reconnecting in 1s...", e) + time.sleep(1) + + async def stream_sse_async( + self, execution_id: str, last_event_id: Optional[str] = None + ) -> AsyncIterator[Dict[str, Any]]: + import asyncio + + import httpx + + url = self._agent_url(f"/stream/{execution_id}") + base_headers = {**(await self._async_auth_header()), "Accept": "text/event-stream"} + first_connect = True + got_real_event = False + + while True: + try: + req_headers = dict(base_headers) + if last_event_id is not None: + req_headers["Last-Event-ID"] = last_event_id + client = self._get_sse_async_client() + async with client.stream( + "GET", url, headers=req_headers, timeout=httpx.Timeout(30.0, connect=5.0) + ) as resp: + if resp.status_code != 200: + if first_connect: + raise SSEUnavailableError(f"Server returned {resp.status_code}") + logger.warning("SSE reconnect failed (status=%s), stopping", resp.status_code) + return + first_connect = False + connect_time = time.monotonic() + async for sse_event in self._parse_sse_async(resp.aiter_lines()): + if sse_event.get("_heartbeat"): + if (not got_real_event + and time.monotonic() - connect_time > _SSE_NO_EVENT_TIMEOUT): + raise SSEUnavailableError( + "SSE connected but no events received " + f"(only heartbeats for {_SSE_NO_EVENT_TIMEOUT}s)" + ) + continue + if sse_event.get("id"): + last_event_id = sse_event["id"] + got_real_event = True + yield sse_event + return + except SSEUnavailableError: + raise + except Exception as e: + if first_connect: + raise SSEUnavailableError(str(e)) + logger.warning("SSE connection lost (%s), reconnecting in 1s...", e) + await asyncio.sleep(1) + + def _get_sse_async_client(self): + import httpx + + if self._sse_async_client is None or self._sse_async_client.is_closed: + self._sse_async_client = httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=5.0)) + return self._sse_async_client + + @staticmethod + def _parse_sse(lines: Iterator[Any]) -> Iterator[Dict[str, Any]]: + """Parse SSE wire format into event dicts (sync). Heartbeats → ``{"_heartbeat": True}``.""" + event_type: Optional[str] = None + event_id: Optional[str] = None + data_lines: List[str] = [] + for raw_line in lines: + line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line + if line.startswith(":"): + yield {"_heartbeat": True} + continue + if line == "": + if data_lines: + data_str = "\n".join(data_lines) + try: + data = json.loads(data_str) + except (json.JSONDecodeError, ValueError): + data = {"content": data_str} + yield {"event": event_type, "id": event_id, "data": data} + event_type = event_id = None + data_lines = [] + continue + if line.startswith("event:"): + event_type = line[6:].strip() + elif line.startswith("id:"): + event_id = line[3:].strip() + elif line.startswith("data:"): + data_lines.append(line[5:].strip()) + + @staticmethod + async def _parse_sse_async(lines: AsyncIterator[str]) -> AsyncIterator[Dict[str, Any]]: + """Async counterpart of :meth:`_parse_sse`.""" + event_type: Optional[str] = None + event_id: Optional[str] = None + data_lines: List[str] = [] + async for raw_line in lines: + line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line + if line.startswith(":"): + yield {"_heartbeat": True} + continue + if line == "": + if data_lines: + data_str = "\n".join(data_lines) + try: + data = json.loads(data_str) + except (json.JSONDecodeError, ValueError): + data = {"content": data_str} + yield {"event": event_type, "id": event_id, "data": data} + event_type = event_id = None + data_lines = [] + continue + if line.startswith("event:"): + event_type = line[6:].strip() + elif line.startswith("id:"): + event_id = line[3:].strip() + elif line.startswith("data:"): + data_lines.append(line[5:].strip()) + + # ── schedules convenience (in-layer SchedulerClient) ───────────────── + + @property + def schedules(self) -> Any: + """Cron schedule lifecycle client (:class:`SchedulerClient`) for this config.""" + if self._schedule_client_instance is None: + from conductor.client.orkes.orkes_scheduler_client import OrkesSchedulerClient + + self._schedule_client_instance = OrkesSchedulerClient(self._configuration) + return self._schedule_client_instance + + # ── lifecycle ──────────────────────────────────────────────────────── + + def close(self) -> None: # sync transport holds no persistent connections + pass + + async def close_async(self) -> None: + if self._sse_async_client is not None and not self._sse_async_client.is_closed: + await self._sse_async_client.aclose() + self._sse_async_client = None + if self._async_api_client is not None: + try: + await self._async_api_client.close() + except Exception: + pass + self._async_api_client = None diff --git a/src/conductor/client/orkes_clients.py b/src/conductor/client/orkes_clients.py index fc6b1d77..0c847169 100644 --- a/src/conductor/client/orkes_clients.py +++ b/src/conductor/client/orkes_clients.py @@ -2,6 +2,7 @@ from typing import Optional, TYPE_CHECKING +from conductor.client.agent_client import AgentClient from conductor.client.authorization_client import AuthorizationClient from conductor.client.configuration.configuration import Configuration @@ -64,18 +65,15 @@ def get_prompt_client(self) -> PromptClient: def get_schema_client(self) -> SchemaClient: return OrkesSchemaClient(self.configuration) - def get_agent_client(self) -> "AgentApiClient": # noqa: F821 - """Transport client for the ``/agent/*`` control-plane endpoints. - - Returns the raw :class:`AgentApiClient` (start/deploy/compile/status/ - respond/stop/signal/SSE). The agent-DX surface (``run``/``deploy`` - sugar, ``AgentHandle``) stays on ``AgentRuntime.client``. - """ + def get_agent_client(self) -> AgentClient: + """Client for the ``/agent/*`` control-plane endpoints (start/deploy/ + compile/status/execution/respond/stop/signal/SSE), built on the shared + ``ApiClient`` token machinery like every other Orkes client.""" # Imported lazily: this module is on virtually every SDK program's import # path and must not grow import-time weight for the agent surface. - from conductor.client.ai.agent_api_client import AgentApiClient + from conductor.client.orkes.orkes_agent_client import OrkesAgentClient - return AgentApiClient(self.configuration) + return OrkesAgentClient(self.configuration) ConductorClients = OrkesClients diff --git a/tests/integration/ai/conftest.py b/tests/integration/ai/conftest.py index 0ef91996..c3193ec8 100644 --- a/tests/integration/ai/conftest.py +++ b/tests/integration/ai/conftest.py @@ -168,7 +168,7 @@ def runtime(): responsible worker process so the TaskHandler monitor can restart it. """ config = AgentConfig.from_env() - with AgentRuntime(config=config) as rt: + with AgentRuntime(settings=config) as rt: watchdog = _WorkerWatchdog(rt) watchdog.start() try: diff --git a/tests/integration/ai/test_behavioral_correctness_live.py b/tests/integration/ai/test_behavioral_correctness_live.py index 08a830ec..d89e60fc 100644 --- a/tests/integration/ai/test_behavioral_correctness_live.py +++ b/tests/integration/ai/test_behavioral_correctness_live.py @@ -53,7 +53,7 @@ def runtime(): config = AgentConfig.from_env() config.streaming_enabled = False # polling generates correct events - rt = AgentRuntime(config=config) + rt = AgentRuntime(settings=config) yield rt rt.shutdown() diff --git a/tests/integration/ai/test_correctness_live.py b/tests/integration/ai/test_correctness_live.py index e527ea7e..e02250ac 100644 --- a/tests/integration/ai/test_correctness_live.py +++ b/tests/integration/ai/test_correctness_live.py @@ -51,7 +51,7 @@ def runtime(): # HANDOFF events. Polling generates correct tool names, handoff # events, and guardrail events from Conductor task inspection. config.streaming_enabled = False - rt = AgentRuntime(config=config) + rt = AgentRuntime(settings=config) yield rt rt.shutdown() diff --git a/tests/serdesertest/test_serdeser_task_runtime_metadata.py b/tests/serdesertest/test_serdeser_task_runtime_metadata.py new file mode 100644 index 00000000..8de735fd --- /dev/null +++ b/tests/serdesertest/test_serdeser_task_runtime_metadata.py @@ -0,0 +1,48 @@ +import unittest + +from conductor.client.http.api_client import ApiClient +from conductor.client.http.models import Task +from conductor.client.http.models.task_def import TaskDef + + +class TestTaskRuntimeMetadata(unittest.TestCase): + """The server delivers host-resolved secret values on Task.runtimeMetadata + (wire-only) when a worker's TaskDef.runtimeMetadata declares secret names + (conductor-oss PR #1255). Verify both models round-trip the field and omit it + when empty.""" + + def setUp(self): + self.client = ApiClient() + + def test_task_runtime_metadata_round_trips(self): + task = Task(task_id="t1", runtime_metadata={"GH_TOKEN": "ghp_secret", "GH_APP_ID": "42"}) + + wire = self.client.sanitize_for_serialization(task) + self.assertIn("runtimeMetadata", wire, "field must serialize under the wire key") + self.assertEqual({"GH_TOKEN": "ghp_secret", "GH_APP_ID": "42"}, wire["runtimeMetadata"]) + + back = self.client.deserialize_class(wire, Task) + self.assertEqual("ghp_secret", back.runtime_metadata["GH_TOKEN"]) + self.assertEqual("42", back.runtime_metadata["GH_APP_ID"]) + + def test_task_runtime_metadata_omitted_when_empty(self): + wire = self.client.sanitize_for_serialization(Task(task_id="t1")) + self.assertNotIn("runtimeMetadata", wire, "absent map must not appear on the wire") + + def test_taskdef_runtime_metadata_round_trips(self): + task_def = TaskDef(name="my_worker", runtime_metadata=["GH_TOKEN", "AWS_ACCESS_KEY_ID"]) + + wire = self.client.sanitize_for_serialization(task_def) + self.assertIn("runtimeMetadata", wire) + self.assertEqual(["GH_TOKEN", "AWS_ACCESS_KEY_ID"], wire["runtimeMetadata"]) + + back = self.client.deserialize_class(wire, TaskDef) + self.assertEqual(["GH_TOKEN", "AWS_ACCESS_KEY_ID"], back.runtime_metadata) + + def test_taskdef_runtime_metadata_omitted_when_empty(self): + wire = self.client.sanitize_for_serialization(TaskDef(name="my_worker")) + self.assertNotIn("runtimeMetadata", wire, "absent list must not appear on the wire") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/ai/credentials/test_fetcher.py b/tests/unit/ai/credentials/test_fetcher.py deleted file mode 100644 index b4d72d9f..00000000 --- a/tests/unit/ai/credentials/test_fetcher.py +++ /dev/null @@ -1,67 +0,0 @@ -# Copyright (c) 2025 Agentspan -# Licensed under the MIT License. See LICENSE file in the project root for details. - -"""Tests for WorkerCredentialFetcher — no mocks, no server required. - -Server-dependent tests live in tests/e2e/test_credential_e2e.py. -""" - -import os -from unittest.mock import patch - -import pytest - -from conductor.ai.agents.runtime.credentials.fetcher import WorkerCredentialFetcher -from conductor.ai.agents.runtime.credentials.types import ( - CredentialNotFoundError, - CredentialServiceError, -) - - -def _make_fetcher(): - return WorkerCredentialFetcher(server_url="http://localhost:8080/api") - - -class TestFetchWithoutToken: - """No execution token — must fail with CredentialNotFoundError.""" - - def test_empty_token_raises(self): - fetcher = _make_fetcher() - with pytest.raises(CredentialNotFoundError): - fetcher.fetch("", ["GITHUB_TOKEN"]) - - def test_none_token_raises(self): - fetcher = _make_fetcher() - with pytest.raises(CredentialNotFoundError): - fetcher.fetch(None, ["GITHUB_TOKEN"]) - - def test_none_token_error_lists_names(self): - fetcher = _make_fetcher() - with pytest.raises(CredentialNotFoundError, match="GITHUB_TOKEN"): - fetcher.fetch(None, ["GITHUB_TOKEN"]) - - def test_empty_names_returns_empty(self): - fetcher = _make_fetcher() - result = fetcher.fetch(None, []) - assert result == {} - - def test_multiple_names_in_error(self): - fetcher = _make_fetcher() - with pytest.raises(CredentialNotFoundError): - fetcher.fetch(None, ["KEY_A", "KEY_B"]) - - -class TestFetchUnreachableServer: - """Network errors when server is not running — always raises, no fallback.""" - - def test_unreachable_server_raises_service_error(self): - fetcher = WorkerCredentialFetcher(server_url="http://127.0.0.1:19999/api") - with pytest.raises(CredentialServiceError): - fetcher.fetch("some-token", ["MY_KEY"]) - - def test_unreachable_server_no_env_fallback(self): - """Even with env var set, unreachable server raises — no silent fallback.""" - fetcher = WorkerCredentialFetcher(server_url="http://127.0.0.1:19999/api") - with patch.dict(os.environ, {"MY_KEY": "from_env"}): - with pytest.raises(CredentialServiceError): - fetcher.fetch("some-token", ["MY_KEY"]) diff --git a/tests/unit/ai/test_agent_http.py b/tests/unit/ai/test_agent_http.py new file mode 100644 index 00000000..83883c07 --- /dev/null +++ b/tests/unit/ai/test_agent_http.py @@ -0,0 +1,144 @@ +# Copyright (c) 2025 Agentspan +# Licensed under the MIT License. See LICENSE file in the project root for details. + +"""Unit tests for ``agent_http`` — the worker HTTP path that posts to Agentspan +``/agent/*`` endpoints through the SDK ``ApiClient``. + +Uses a real in-process HTTP server (no mocks, per repo test policy) serving both +the ``POST /api/token`` mint (counted) and the ``/api/agent/*`` endpoints, so we +can assert the SDK's single token authority: one mint per worker (per +``(server_url, auth_key)``), with ``X-Authorization`` on every agent request. +""" + +import base64 +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +from conductor.ai.agents._internal import agent_http +from conductor.ai.agents._internal.agent_http import agent_post + + +def _jwt(exp: int) -> str: + header = base64.urlsafe_b64encode(b'{"alg":"HS256"}').rstrip(b"=").decode() + payload = base64.urlsafe_b64encode(json.dumps({"exp": exp}).encode()).rstrip(b"=").decode() + return f"{header}.{payload}.sig" + + +_TOKEN = _jwt(4102444800) # far future — no mid-test TTL refresh + + +class _Handler(BaseHTTPRequestHandler): + mint_count = 0 + agent_requests = [] # list of {"path", "x_auth", "body"} + agent_status = 200 + agent_body = {} + + def do_POST(self): # noqa: N802 + length = int(self.headers.get("Content-Length", 0)) + raw = self.rfile.read(length) if length else b"" + if self.path == "/api/token": + body = json.loads(raw or b"{}") + if body.get("keyId") != "kid" or body.get("keySecret") != "ksec": + self._send(401) + return + type(self).mint_count += 1 + self._send_json(200, {"token": _TOKEN}) + return + if self.path.startswith("/api/agent/"): + type(self).agent_requests.append( + { + "path": self.path, + "x_auth": self.headers.get("X-Authorization"), + "body": json.loads(raw) if raw else None, + } + ) + if type(self).agent_status >= 400: + self._send(type(self).agent_status) + return + self._send_json(type(self).agent_status, type(self).agent_body) + return + self._send(404) + + def _send(self, code): + self.send_response(code) + self.send_header("Content-Length", "0") + self.end_headers() + + def _send_json(self, code, obj): + data = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, *args): # silence + pass + + +@pytest.fixture() +def server(): + _Handler.mint_count = 0 + _Handler.agent_requests = [] + _Handler.agent_status = 200 + _Handler.agent_body = {} + agent_http._API_CLIENTS.clear() # fresh ApiClient cache per test + + srv = HTTPServer(("127.0.0.1", 0), _Handler) + t = threading.Thread(target=srv.serve_forever, daemon=True) + t.start() + # server_url == the SDK "host": already ends in /api. + yield f"http://127.0.0.1:{srv.server_address[1]}/api" + srv.shutdown() + agent_http._API_CLIENTS.clear() + + +def test_post_mints_once_and_caches(server): + """Two posts with the same (server_url, auth_key) ⇒ exactly ONE /token mint, + and every agent request carries X-Authorization (single token authority).""" + agent_post(server, "kid", "ksec", "/agent/events/e1", {"type": "a"}) + agent_post(server, "kid", "ksec", "/agent/events/e1", {"type": "b"}) + + assert _Handler.mint_count == 1 # cached ApiClient ⇒ no per-call mint + assert len(_Handler.agent_requests) == 2 + assert all(r["x_auth"] == _TOKEN for r in _Handler.agent_requests) + assert [r["body"] for r in _Handler.agent_requests] == [{"type": "a"}, {"type": "b"}] + + +def test_read_response_returns_dict(server): + _Handler.agent_body = {"executionId": "wf-123"} + resp = agent_post(server, "kid", "ksec", "/agent/execution", {"workflowName": "w"}, + read_response=True) + assert resp == {"executionId": "wf-123"} + + +def test_read_response_false_returns_none_but_posts(server): + _Handler.agent_body = {"executionId": "wf-123"} + result = agent_post(server, "kid", "ksec", "/agent/events/e1", {"type": "x"}) + assert result is None # fire-and-forget shape + assert len(_Handler.agent_requests) == 1 # request still executed + assert _Handler.agent_requests[0]["body"] == {"type": "x"} + + +def test_server_error_returns_none(server): + _Handler.agent_status = 500 + resp = agent_post(server, "kid", "ksec", "/agent/execution", {}, read_response=True) + assert resp is None # ApiException swallowed → graceful degradation + + +def test_404_returns_none_not_raise(server): + _Handler.agent_status = 404 + resp = agent_post(server, "kid", "ksec", "/agent/999/tasks", {}, read_response=True) + assert resp is None # degrades; does NOT raise AgentNotFoundError + + +def test_anonymous_no_mint_no_auth_header(server): + """Empty auth_key ⇒ no authentication settings ⇒ no /token mint and no + X-Authorization, but the agent request still goes out.""" + agent_post(server, "", "", "/agent/events/e1", {"type": "anon"}) + assert _Handler.mint_count == 0 + assert len(_Handler.agent_requests) == 1 + assert _Handler.agent_requests[0]["x_auth"] is None diff --git a/tests/unit/ai/test_cli_config.py b/tests/unit/ai/test_cli_config.py index 75da46bd..60e344a0 100644 --- a/tests/unit/ai/test_cli_config.py +++ b/tests/unit/ai/test_cli_config.py @@ -288,6 +288,27 @@ def test_context_key_empty_string_is_noop(self): tool_fn.__wrapped__(command="echo", context_key="", context=ctx) assert ctx.state == {} + def test_cli_tool_worker_is_spawn_safe(self): + """Regression: the auto-generated run_command worker must be picklable so + registration's spawn-safety probe passes. It was a `` closure + (`_make_cli_tool..run_command`) that failed to pickle; it is now a + module-level `_CliCommandRunner` instance carrying config as data.""" + import pickle + + from conductor.ai.agents.runtime._dispatch import make_tool_worker + from conductor.ai.agents.runtime._worker_entries import probe_spawn_safety + + tool_fn = _make_cli_tool( + allowed_commands=["gh", "aws"], timeout=15, agent_name="devops_agent" + ) + td = tool_fn._tool_def + # The raw callable that gets registered/pickled must survive pickling. + pickle.dumps(td.func) + # And the full registration probe (the exact path that used to raise + # SpawnSafetyError) must pass. + worker = make_tool_worker(td.func, td.name, tool_def=td) + probe_spawn_safety(worker, td.name, group="tools") + class TestAgentCliIntegration: """Test Agent integration with CLI tools.""" diff --git a/tests/unit/ai/test_config_env.py b/tests/unit/ai/test_config_env.py index e9910c2b..864b3aff 100644 --- a/tests/unit/ai/test_config_env.py +++ b/tests/unit/ai/test_config_env.py @@ -1,17 +1,26 @@ # Copyright (c) 2025 Agentspan # Licensed under the MIT License. See LICENSE file in the project root for details. -"""Tests for AgentConfig environment variable loading. +"""Tests for AgentConfig environment loading and SDK-wide log-level config. -Verifies that AGENTSPAN_* env vars are loaded correctly via from_env(). +AgentConfig holds only agent-runtime settings (worker pool + liveness). +Connection, auth, and the log level live on the conductor ``Configuration``. """ from __future__ import annotations +import logging import os from unittest import mock -from conductor.ai.agents.runtime.config import AgentConfig, _env, _env_bool, _env_int +from conductor.ai.agents.runtime.config import ( + AgentConfig, + _env, + _env_bool, + _env_float, + _env_int, +) +from conductor.client.configuration.configuration import Configuration class TestEnvHelper: @@ -72,45 +81,31 @@ def test_empty_string_uses_default(self): assert _env_int("NUM", 7) == 7 -class TestAgentConfigFromEnv: - """Tests for AgentConfig.from_env().""" +class TestEnvFloat: + """Tests for _env_float() helper.""" - def test_reads_agentspan_server_url(self): - env = {"AGENTSPAN_SERVER_URL": "http://myhost:9090/api"} - with mock.patch.dict(os.environ, env, clear=True): - config = AgentConfig.from_env() - assert config.server_url == "http://myhost:9090/api" + def test_reads_float(self): + with mock.patch.dict(os.environ, {"SECS": "12.5"}, clear=True): + assert _env_float("SECS") == 12.5 - def test_defaults_to_localhost_when_nothing_set(self): + def test_default(self): with mock.patch.dict(os.environ, {}, clear=True): - config = AgentConfig.from_env() - assert config.server_url == "http://localhost:8080/api" + assert _env_float("SECS", 30.0) == 30.0 - def test_reads_agentspan_auth_key(self): - env = {"AGENTSPAN_AUTH_KEY": "mykey", "AGENTSPAN_AUTH_SECRET": "mysecret"} - with mock.patch.dict(os.environ, env, clear=True): - config = AgentConfig.from_env() - assert config.auth_key == "mykey" - assert config.auth_secret == "mysecret" + def test_empty_string_uses_default(self): + with mock.patch.dict(os.environ, {"SECS": ""}, clear=True): + assert _env_float("SECS", 30.0) == 30.0 - def test_reads_auth_key_via_env(self): - env = {"AGENTSPAN_AUTH_KEY": "key2"} - with mock.patch.dict(os.environ, env, clear=True): - config = AgentConfig.from_env() - assert config.auth_key == "key2" - # api_key is a separate field populated from AGENTSPAN_API_KEY - assert config.api_key is None - def test_auto_start_server_defaults_true(self): - with mock.patch.dict(os.environ, {}, clear=True): - config = AgentConfig.from_env() - assert config.auto_start_server is True +class TestAgentConfigFromEnv: + """Tests for AgentConfig.from_env() — agent-runtime settings only.""" - def test_auto_start_server_env_false(self): - env = {"AGENTSPAN_AUTO_START_SERVER": "false"} - with mock.patch.dict(os.environ, env, clear=True): + def test_defaults(self): + with mock.patch.dict(os.environ, {}, clear=True): config = AgentConfig.from_env() - assert config.auto_start_server is False + assert config.worker_poll_interval_ms == 100 + assert config.worker_thread_count == 1 + assert config.auto_start_workers is True def test_boolean_env_vars(self): env = { @@ -126,156 +121,76 @@ def test_boolean_env_vars(self): def test_numeric_env_vars(self): env = { - "AGENTSPAN_LLM_RETRY_COUNT": "5", + "AGENTSPAN_WORKER_POLL_INTERVAL": "250", "AGENTSPAN_WORKER_THREADS": "4", } with mock.patch.dict(os.environ, env, clear=True): config = AgentConfig.from_env() - assert config.llm_retry_count == 5 + assert config.worker_poll_interval_ms == 250 assert config.worker_thread_count == 4 def test_direct_construction(self): - config = AgentConfig(server_url="http://test:9090/api") - assert config.server_url == "http://test:9090/api" - + config = AgentConfig(worker_thread_count=8) + assert config.worker_thread_count == 8 -class TestServerUrlNormalisation: - """Tests for BUG-P2-10: auto-append /api when missing.""" - def test_appends_api_when_missing(self): - config = AgentConfig(server_url="http://localhost:8080") - assert config.server_url == "http://localhost:8080/api" +class TestLivenessConfig: + """Liveness monitor settings (used by stateful runs).""" - def test_appends_api_with_trailing_slash(self): - config = AgentConfig(server_url="http://localhost:8080/") - assert config.server_url == "http://localhost:8080/api" - - def test_leaves_correct_url_unchanged(self): - config = AgentConfig(server_url="http://localhost:8080/api") - assert config.server_url == "http://localhost:8080/api" + def test_defaults(self): + with mock.patch.dict(os.environ, {}, clear=True): + config = AgentConfig.from_env() + assert config.liveness_enabled is True + assert config.liveness_stall_seconds == 30.0 + assert config.liveness_check_interval_seconds == 10.0 - def test_leaves_correct_url_with_trailing_slash(self): - config = AgentConfig(server_url="http://localhost:8080/api/") - assert config.server_url == "http://localhost:8080/api" + def test_from_env(self): + env = { + "AGENTSPAN_LIVENESS_ENABLED": "false", + "AGENTSPAN_LIVENESS_STALL_SECONDS": "45", + "AGENTSPAN_LIVENESS_CHECK_INTERVAL_SECONDS": "5", + } + with mock.patch.dict(os.environ, env, clear=True): + config = AgentConfig.from_env() + assert config.liveness_enabled is False + assert config.liveness_stall_seconds == 45.0 + assert config.liveness_check_interval_seconds == 5.0 - def test_remote_url_without_api(self): - config = AgentConfig(server_url="https://play.orkes.io") - assert config.server_url == "https://play.orkes.io/api" - def test_from_env_auto_appends(self): - with mock.patch.dict( - os.environ, {"AGENTSPAN_SERVER_URL": "http://myhost:9090"}, clear=True - ): - config = AgentConfig.from_env() - assert config.server_url == "http://myhost:9090/api" +class TestConfigurationLogLevel: + """The SDK-wide log level lives on Configuration (not AgentConfig).""" - def test_default_url_has_api(self): + def test_default_is_info(self): with mock.patch.dict(os.environ, {}, clear=True): - config = AgentConfig.from_env() - assert config.server_url == "http://localhost:8080/api" + assert Configuration().log_level == logging.INFO + def test_debug_flag_sets_debug_level(self): + with mock.patch.dict(os.environ, {}, clear=True): + assert Configuration(debug=True).log_level == logging.DEBUG -class TestLogLevelConfig: - """Tests for BUG-P3-04: log_level configuration field.""" + def test_explicit_level_name(self): + with mock.patch.dict(os.environ, {}, clear=True): + assert Configuration(log_level="WARNING").log_level == logging.WARNING - def test_default_log_level(self): + def test_explicit_level_int(self): with mock.patch.dict(os.environ, {}, clear=True): - config = AgentConfig.from_env() - assert config.log_level == "INFO" + assert Configuration(log_level=logging.ERROR).log_level == logging.ERROR - def test_log_level_from_env(self): - with mock.patch.dict(os.environ, {"AGENTSPAN_LOG_LEVEL": "WARNING"}, clear=True): - config = AgentConfig.from_env() - assert config.log_level == "WARNING" + def test_conductor_log_level_env(self): + with mock.patch.dict(os.environ, {"CONDUCTOR_LOG_LEVEL": "WARNING"}, clear=True): + assert Configuration().log_level == logging.WARNING - def test_log_level_debug(self): + def test_agentspan_log_level_env_fallback(self): with mock.patch.dict(os.environ, {"AGENTSPAN_LOG_LEVEL": "DEBUG"}, clear=True): - config = AgentConfig.from_env() - assert config.log_level == "DEBUG" + assert Configuration().log_level == logging.DEBUG - def test_log_level_empty_string_uses_default(self): - with mock.patch.dict(os.environ, {"AGENTSPAN_LOG_LEVEL": ""}, clear=True): - config = AgentConfig.from_env() - assert config.log_level == "INFO" - - @mock.patch("conductor.ai.agents.runtime.server._is_server_ready", return_value=True) - def test_log_level_applied_to_logger(self, mock_ready): - """AgentRuntime.__init__ applies log_level to the conductor.ai logger.""" - import logging - - config = AgentConfig( - server_url="http://localhost:8080/api", - log_level="WARNING", - ) + def test_applied_to_logger_by_runtime(self): + """AgentRuntime applies Configuration.log_level to the conductor.ai logger.""" with mock.patch("conductor.client.orkes_clients.OrkesClients"): with mock.patch("conductor.ai.agents.runtime.worker_manager.WorkerManager"): from conductor.ai.agents.runtime.runtime import AgentRuntime - rt = AgentRuntime(config=config) + AgentRuntime(Configuration(log_level="WARNING")) assert logging.getLogger("conductor.ai").level == logging.WARNING - # Reset to avoid affecting other tests - logging.getLogger("conductor.ai").setLevel(logging.INFO) - - -class TestAgentConfigCredentialFields: - """secret_strict_mode and api_key fields.""" - - def test_credential_strict_mode_defaults_false(self): - from conductor.ai.agents.runtime.config import AgentConfig - - config = AgentConfig() - assert config.secret_strict_mode is False - - def test_credential_strict_mode_can_be_set(self): - from conductor.ai.agents.runtime.config import AgentConfig - - config = AgentConfig(secret_strict_mode=True) - assert config.secret_strict_mode is True - - def test_credential_strict_mode_from_env_true(self): - import os - from unittest import mock - from conductor.ai.agents.runtime.config import AgentConfig - - with mock.patch.dict(os.environ, {"AGENTSPAN_SECRET_STRICT_MODE": "true"}): - config = AgentConfig.from_env() - assert config.secret_strict_mode is True - - def test_credential_strict_mode_from_env_false(self): - import os - from unittest import mock - from conductor.ai.agents.runtime.config import AgentConfig - - with mock.patch.dict(os.environ, {"AGENTSPAN_SECRET_STRICT_MODE": "false"}): - config = AgentConfig.from_env() - assert config.secret_strict_mode is False - - def test_api_key_field_defaults_none(self): - from conductor.ai.agents.runtime.config import AgentConfig - - config = AgentConfig() - # api_key field (new) takes precedence; auth_key kept for backward compat - assert config.api_key is None - - def test_api_key_field_can_be_set(self): - from conductor.ai.agents.runtime.config import AgentConfig - - config = AgentConfig(api_key="asp_my_key") - assert config.api_key == "asp_my_key" - - def test_api_key_from_env(self): - import os - from unittest import mock - from conductor.ai.agents.runtime.config import AgentConfig - - with mock.patch.dict(os.environ, {"AGENTSPAN_API_KEY": "asp_env_key"}): - config = AgentConfig.from_env() - assert config.api_key == "asp_env_key" - - def test_auth_key_backward_compat_still_works(self): - """auth_key must still be accepted for backward compat.""" - from conductor.ai.agents.runtime.config import AgentConfig - - config = AgentConfig(auth_key="old_key") - assert config.auth_key == "old_key" + logging.getLogger("conductor.ai").setLevel(logging.INFO) # reset diff --git a/tests/unit/ai/test_context_passing.py b/tests/unit/ai/test_context_passing.py index 6a88d243..b2b23a74 100644 --- a/tests/unit/ai/test_context_passing.py +++ b/tests/unit/ai/test_context_passing.py @@ -15,15 +15,12 @@ def test_start_via_server_includes_context_in_payload(): agent = Agent(name="test", model="anthropic/claude-sonnet-4-6") rt = AgentRuntime() - with patch("requests.post") as mock_post: - mock_resp = MagicMock() - mock_resp.json.return_value = {"executionId": "test-id", "requiredWorkers": []} - mock_resp.raise_for_status.return_value = None - mock_post.return_value = mock_resp - rt._start_via_server(agent, "hello", context={"repo": "test/repo"}) - payload = mock_post.call_args.kwargs["json"] - assert "context" in payload - assert payload["context"] == {"repo": "test/repo"} + rt._agent_client = MagicMock() + rt._agent_client.start_agent.return_value = {"executionId": "test-id", "requiredWorkers": []} + rt._start_via_server(agent, "hello", context={"repo": "test/repo"}) + payload = rt._agent_client.start_agent.call_args[0][0] + assert "context" in payload + assert payload["context"] == {"repo": "test/repo"} def test_start_via_server_without_context_omits_key(): @@ -32,14 +29,11 @@ def test_start_via_server_without_context_omits_key(): agent = Agent(name="test", model="anthropic/claude-sonnet-4-6") rt = AgentRuntime() - with patch("requests.post") as mock_post: - mock_resp = MagicMock() - mock_resp.json.return_value = {"executionId": "test-id", "requiredWorkers": []} - mock_resp.raise_for_status.return_value = None - mock_post.return_value = mock_resp - rt._start_via_server(agent, "hello") - payload = mock_post.call_args.kwargs["json"] - assert "context" not in payload + rt._agent_client = MagicMock() + rt._agent_client.start_agent.return_value = {"executionId": "test-id", "requiredWorkers": []} + rt._start_via_server(agent, "hello") + payload = rt._agent_client.start_agent.call_args[0][0] + assert "context" not in payload def test_context_key_collision_with_state_updates(): diff --git a/tests/unit/ai/test_credential_injection_integration.py b/tests/unit/ai/test_credential_injection_integration.py index 7526e3eb..198bd660 100644 --- a/tests/unit/ai/test_credential_injection_integration.py +++ b/tests/unit/ai/test_credential_injection_integration.py @@ -11,7 +11,7 @@ pytest.importorskip("langchain_core", reason="langchain_core not installed") import os -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from conductor.client.http.models import Task, TaskResult @@ -22,14 +22,16 @@ # --------------------------------------------------------------------------- -def _real_conductor_task(workflow_instance_id="wf-integ-001"): - """Build a real Conductor Task object (not a mock).""" +def _real_conductor_task(workflow_instance_id="wf-integ-001", runtime_metadata=None): + """Build a real Conductor Task object (not a mock). + + The host delivers resolved secrets on ``Task.runtimeMetadata`` (wire-only). + """ task = Task() task.task_id = "task-integ-001" task.workflow_instance_id = workflow_instance_id - task.input_data = { - "__agentspan_ctx__": {"execution_token": "tok-integ-fake"}, - } + task.input_data = {} + task.runtime_metadata = runtime_metadata or {} return task @@ -62,10 +64,6 @@ def _make_lc_tool_and_graph(): return graph, check_github_token -# Patch target: the credential fetcher factory in _dispatch (the only external dep) -_FETCHER_PATCH = "conductor.ai.agents.runtime._dispatch._get_credential_fetcher" - - # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @@ -109,12 +107,8 @@ def test_extracted_tool_receives_credential_in_environ(self): credential_names=["GITHUB_TOKEN"], ) - fake_fetcher = MagicMock() - fake_fetcher.fetch.return_value = {"GITHUB_TOKEN": "ghp_real_token_123"} - task = _real_conductor_task() - - with patch(_FETCHER_PATCH, return_value=fake_fetcher): - result = worker_fn(task) + task = _real_conductor_task(runtime_metadata={"GITHUB_TOKEN": "ghp_real_token_123"}) + result = worker_fn(task) # The tool saw the credential during execution assert result.status.name == "COMPLETED" @@ -123,9 +117,6 @@ def test_extracted_tool_receives_credential_in_environ(self): # Credential was cleaned up from env assert "GITHUB_TOKEN" not in os.environ - # Fetcher was called with the correct token and credential names - fake_fetcher.fetch.assert_called_once_with("tok-integ-fake", ["GITHUB_TOKEN"]) - def test_extracted_tool_without_credentials_sees_empty_env(self): """Without credential_names, the tool sees no GITHUB_TOKEN.""" from conductor.ai.agents.frameworks.serializer import serialize_agent @@ -150,13 +141,10 @@ def test_extracted_tool_without_credentials_sees_empty_env(self): os.environ.pop("GITHUB_TOKEN", None) task = _real_conductor_task() - - with patch(_FETCHER_PATCH) as mock_get_fetcher: - result = worker_fn(task) + result = worker_fn(task) assert result.status.name == "COMPLETED" assert "NOT_FOUND" in str(result.output_data) - mock_get_fetcher.assert_not_called() def test_credential_cleanup_on_tool_exception(self): """Credentials are cleaned up even when the tool raises.""" @@ -173,12 +161,8 @@ def failing_tool(): credential_names=["SECRET_KEY"], ) - fake_fetcher = MagicMock() - fake_fetcher.fetch.return_value = {"SECRET_KEY": "s3cr3t"} - task = _real_conductor_task() - - with patch(_FETCHER_PATCH, return_value=fake_fetcher): - result = worker_fn(task) + task = _real_conductor_task(runtime_metadata={"SECRET_KEY": "s3cr3t"}) + result = worker_fn(task) assert result.status.name == "FAILED" assert "SECRET_KEY" not in os.environ @@ -206,12 +190,7 @@ def spy_make_tool_worker(*args, **kwargs): from conductor.ai.agents.runtime.runtime import AgentRuntime from conductor.ai.agents.runtime.config import AgentConfig - config = AgentConfig( - server_url="http://testserver:8080/api", - auth_key="k", - auth_secret="s", - auto_start_workers=False, - ) + config = AgentConfig(auto_start_workers=False) runtime = AgentRuntime.__new__(AgentRuntime) runtime._config = config runtime._worker_start_lock = __import__("threading").Lock() diff --git a/tests/unit/ai/test_deploy_serve.py b/tests/unit/ai/test_deploy_serve.py index f8c00846..0dca4707 100644 --- a/tests/unit/ai/test_deploy_serve.py +++ b/tests/unit/ai/test_deploy_serve.py @@ -17,11 +17,8 @@ def _make_runtime(): from conductor.ai.agents.runtime.runtime import AgentRuntime from conductor.ai.agents.runtime.config import AgentConfig - config = AgentConfig( - server_url="http://fake:8080", - auto_start_workers=False, - ) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) # ── Deploy tests ────────────────────────────────────────────────────── @@ -141,6 +138,45 @@ def test_serve_starts_worker_manager(self): mock_start.assert_called_once() assert rt._workers_started + def test_serve_registers_agent_on_server(self): + """serve() also registers the agent on the server (serve = deploy + serve).""" + rt = _make_runtime() + agent = Agent(name="bot", model="openai/gpt-4o") + with patch.object(rt, "_deploy_via_server") as mock_deploy: + with patch.object(rt, "_register_workers"): + with patch.object(rt, "_collect_worker_names", return_value={"t"}): + with patch.object(rt._worker_manager, "start"): + rt.serve(agent, blocking=False) + mock_deploy.assert_called_once() + assert mock_deploy.call_args.args[0] is agent + + def test_serve_registers_each_of_multiple_agents(self): + rt = _make_runtime() + a1 = Agent(name="a1", model="openai/gpt-4o") + a2 = Agent(name="a2", model="openai/gpt-4o") + with patch.object(rt, "_deploy_via_server") as mock_deploy: + with patch.object(rt, "_register_workers"): + with patch.object(rt, "_collect_worker_names", return_value=set()): + with patch.object(rt._worker_manager, "start"): + rt.serve(a1, a2, blocking=False) + assert mock_deploy.call_count == 2 + + def test_serve_registers_before_starting_workers(self): + """The agent is registered on the server before workers start polling.""" + rt = _make_runtime() + agent = Agent(name="bot", model="openai/gpt-4o") + calls = [] + with patch.object( + rt, "_deploy_via_server", side_effect=lambda *a, **k: calls.append("deploy") + ): + with patch.object(rt, "_register_workers"): + with patch.object(rt, "_collect_worker_names", return_value={"t"}): + with patch.object( + rt._worker_manager, "start", side_effect=lambda *a, **k: calls.append("start") + ): + rt.serve(agent, blocking=False) + assert calls == ["deploy", "start"] + # ── Run/Start/Stream by name tests ────────────────────────────────── diff --git a/tests/unit/ai/test_dispatch.py b/tests/unit/ai/test_dispatch.py index cd34496f..bf26627c 100644 --- a/tests/unit/ai/test_dispatch.py +++ b/tests/unit/ai/test_dispatch.py @@ -78,62 +78,45 @@ def test_none_tool_calls(self): assert result["needs_approval"] is False -class TestCredentialExtraction: - """_dispatch.py extracts __agentspan_ctx__ from task input/variables.""" +class TestCredentialResolution: + """_dispatch.py reads host-resolved secrets from Task.runtimeMetadata.""" - def test_extract_token_from_input_data_dict(self): - from conductor.ai.agents.runtime._dispatch import _extract_execution_token + def test_resolves_declared_names_from_runtime_metadata(self): + from conductor.ai.agents.runtime._dispatch import _resolve_secrets_from_task class FakeTask: - input_data = { - "__agentspan_ctx__": {"execution_token": "token-from-input"}, - "x": "hello", - } - workflow_input = {} + runtime_metadata = {"GH_TOKEN": "ghp_x", "OTHER": "y"} - token = _extract_execution_token(FakeTask()) - assert token == "token-from-input" + resolved = _resolve_secrets_from_task(FakeTask(), ["GH_TOKEN"]) + assert resolved == {"GH_TOKEN": "ghp_x"} - def test_extract_token_from_input_data_string(self): - """Backwards compat: plain string is also accepted.""" - from conductor.ai.agents.runtime._dispatch import _extract_execution_token + def test_empty_names_returns_empty(self): + from conductor.ai.agents.runtime._dispatch import _resolve_secrets_from_task class FakeTask: - input_data = {"__agentspan_ctx__": "token-from-input", "x": "hello"} - workflow_input = {} + runtime_metadata = {"GH_TOKEN": "ghp_x"} - token = _extract_execution_token(FakeTask()) - assert token == "token-from-input" + assert _resolve_secrets_from_task(FakeTask(), []) == {} - def test_extract_token_returns_none_when_absent(self): - from conductor.ai.agents.runtime._dispatch import _extract_execution_token + def test_missing_name_raises_credential_not_found(self): + from conductor.ai.agents.runtime._dispatch import _resolve_secrets_from_task + from conductor.ai.agents.runtime.credentials.types import CredentialNotFoundError class FakeTask: - input_data = {"x": "hello"} - workflow_input = {} + runtime_metadata = {"GH_TOKEN": "ghp_x"} - token = _extract_execution_token(FakeTask()) - assert token is None + with pytest.raises(CredentialNotFoundError, match="MISSING_ONE"): + _resolve_secrets_from_task(FakeTask(), ["GH_TOKEN", "MISSING_ONE"]) - def test_extract_token_from_workflow_input_dict(self): - from conductor.ai.agents.runtime._dispatch import _extract_execution_token + def test_absent_runtime_metadata_raises_for_declared_name(self): + from conductor.ai.agents.runtime._dispatch import _resolve_secrets_from_task + from conductor.ai.agents.runtime.credentials.types import CredentialNotFoundError class FakeTask: - input_data = {} - workflow_input = {"__agentspan_ctx__": {"execution_token": "token-from-wf"}} + runtime_metadata = None - token = _extract_execution_token(FakeTask()) - assert token == "token-from-wf" - - def test_extract_token_empty_dict_returns_none(self): - from conductor.ai.agents.runtime._dispatch import _extract_execution_token - - class FakeTask: - input_data = {"__agentspan_ctx__": {}} - workflow_input = {} - - token = _extract_execution_token(FakeTask()) - assert token is None + with pytest.raises(CredentialNotFoundError, match="GH_TOKEN"): + _resolve_secrets_from_task(FakeTask(), ["GH_TOKEN"]) class TestToolDefCredentialsSurvival: diff --git a/tests/unit/ai/test_http_client.py b/tests/unit/ai/test_http_client.py deleted file mode 100644 index c7ef41d2..00000000 --- a/tests/unit/ai/test_http_client.py +++ /dev/null @@ -1,292 +0,0 @@ -# Copyright (c) 2025 Agentspan -# Licensed under the MIT License. See LICENSE file in the project root for details. - -"""Tests for the async AgentClient (formerly AgentHttpClient).""" - -from __future__ import annotations - -import json - -import httpx -import pytest - -from conductor.ai.agents.runtime.http_client import ( - AgentClient, - AgentHttpClient, -) - - -def test_agent_http_client_is_backward_compat_alias(): - """The old name must still resolve to the renamed class.""" - assert AgentHttpClient is AgentClient - - -# ── Helpers ────────────────────────────────────────────────────────────── - - -def _make_client(handler, **auth) -> AgentClient: - """Create an AgentClient backed by a mock transport. - - Anonymous by default — pass api_key/auth_key/auth_secret to exercise auth. - """ - client = AgentClient(server_url="http://test-server/api", **auth) - # Override the lazy client with a mock-transport client. Auth headers are - # attached per-request by _auth_headers(), not as client defaults. - client._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) - return client - - -# ── Tests ──────────────────────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_start_agent(): - """POST /agent/start returns executionId.""" - - async def handler(request: httpx.Request) -> httpx.Response: - assert request.method == "POST" - assert request.url.path == "/api/agent/start" - body = json.loads(request.content) - assert body["prompt"] == "hello" - return httpx.Response(200, json={"executionId": "wf-123"}) - - client = _make_client(handler) - result = await client.start_agent({"prompt": "hello"}) - assert result["executionId"] == "wf-123" - await client.close() - - -@pytest.mark.asyncio -async def test_compile_agent(): - """POST /agent/compile returns agent def.""" - - async def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/api/agent/compile" - return httpx.Response(200, json={"workflowDef": {"name": "test_wf"}}) - - client = _make_client(handler) - result = await client.compile_agent({"name": "test"}) - assert result["workflowDef"]["name"] == "test_wf" - await client.close() - - -@pytest.mark.asyncio -async def test_get_status(): - """GET /agent/{id}/status returns status dict.""" - - async def handler(request: httpx.Request) -> httpx.Response: - assert request.method == "GET" - assert "/wf-123/status" in str(request.url) - return httpx.Response( - 200, - json={ - "status": "COMPLETED", - "isComplete": True, - "isRunning": False, - "isWaiting": False, - "output": "done", - }, - ) - - client = _make_client(handler) - result = await client.get_status("wf-123") - assert result["status"] == "COMPLETED" - assert result["isComplete"] is True - await client.close() - - -@pytest.mark.asyncio -async def test_respond(): - """POST /agent/{id}/respond succeeds.""" - called = {} - - async def handler(request: httpx.Request) -> httpx.Response: - assert request.method == "POST" - assert "/wf-123/respond" in str(request.url) - called["body"] = json.loads(request.content) - return httpx.Response(200, json={}) - - client = _make_client(handler) - await client.respond("wf-123", {"approved": True}) - assert called["body"] == {"approved": True} - await client.close() - - -@pytest.mark.asyncio -async def test_http_error_raises(): - """Non-2xx responses raise AgentAPIError (wrapping httpx.HTTPStatusError).""" - from conductor.ai.agents.exceptions import AgentAPIError - - async def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(500, text="Internal Server Error") - - client = _make_client(handler) - with pytest.raises(AgentAPIError) as exc_info: - await client.start_agent({"prompt": "test"}) - assert exc_info.value.status_code == 500 - await client.close() - - -@pytest.mark.asyncio -async def test_parse_sse_async(): - """SSE parsing handles events, heartbeats, and multi-line data.""" - - async def lines(): - for line in [ - ": heartbeat", - "event: thinking", - 'data: {"content": "processing"}', - "", - "event: done", - "id: 42", - 'data: {"output": "result"}', - "", - ]: - yield line - - events = [] - async for event in AgentClient._parse_sse_async(lines()): - events.append(event) - - assert events[0] == {"_heartbeat": True} - assert events[1]["event"] == "thinking" - assert events[1]["data"]["content"] == "processing" - assert events[2]["event"] == "done" - assert events[2]["id"] == "42" - assert events[2]["data"]["output"] == "result" - - -@pytest.mark.asyncio -async def test_close_idempotent(): - """Closing twice doesn't error.""" - - async def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={}) - - client = _make_client(handler) - await client.close() - await client.close() # should not raise - - -# ── Auth: X-Authorization via api_key or minted token (orkes hosts) ────── - - -@pytest.mark.asyncio -async def test_anonymous_sends_no_auth_header(): - """No api_key and no auth_key/secret → no X-Authorization header.""" - - async def handler(request: httpx.Request) -> httpx.Response: - assert "x-authorization" not in request.headers - return httpx.Response(200, json={"executionId": "wf-1"}) - - client = _make_client(handler) - await client.start_agent({"prompt": "test"}) - await client.close() - - -@pytest.mark.asyncio -async def test_api_key_sends_x_authorization(): - """An explicit api_key is already a token — sent directly, no /token call.""" - - async def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path != "/api/token", "api_key must not mint a token" - assert request.headers.get("x-authorization") == "my-api-token" - return httpx.Response(200, json={"executionId": "wf-1"}) - - client = _make_client(handler, api_key="my-api-token") - await client.start_agent({"prompt": "test"}) - await client.close() - - -def _jwt_with_exp(exp: int) -> str: - """Build a fake JWT whose payload carries the given exp (epoch seconds).""" - import base64 - - def b64url(d: dict) -> str: - return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=").decode() - - return f"{b64url({'alg': 'HS256'})}.{b64url({'exp': exp})}.sig" - - -@pytest.mark.asyncio -async def test_auth_key_mints_token_and_caches_it(): - """A minted token is stored on the Configuration and reused across - requests until its TTL elapses — minted exactly once.""" - token_calls = {"count": 0} - jwt = _jwt_with_exp(4102444800) # ~2100 → far future - - async def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/api/token": - token_calls["count"] += 1 - body = json.loads(request.content) - assert body == {"keyId": "key1", "keySecret": "secret1"} - return httpx.Response(200, json={"token": jwt}) - assert request.headers.get("x-authorization") == jwt - return httpx.Response(200, json={"executionId": "wf-1"}) - - client = _make_client(handler, auth_key="key1", auth_secret="secret1") - await client.start_agent({"prompt": "one"}) - await client.start_agent({"prompt": "two"}) - assert token_calls["count"] == 1 # decodable future exp → cached - await client.close() - - -@pytest.mark.asyncio -async def test_opaque_token_cached_for_configuration_ttl(): - """An opaque (non-JWT) token is cached like any other: stored on the - Configuration and reused until auth_token_ttl_min elapses — the same - fixed-TTL renewal rule every generated client uses, so a stale token can - never be served indefinitely.""" - token_calls = {"count": 0} - - async def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/api/token": - token_calls["count"] += 1 - return httpx.Response(200, json={"token": "opaque-no-exp"}) - return httpx.Response(200, json={"executionId": "wf-1"}) - - client = _make_client(handler, auth_key="key1", auth_secret="secret1") - await client.start_agent({"prompt": "one"}) - await client.start_agent({"prompt": "two"}) - assert token_calls["count"] == 1 # cached on the Configuration for its TTL - - # Force the TTL to lapse → the next request re-mints. - client._api._configuration.token_update_time = 0 - await client.start_agent({"prompt": "three"}) - assert token_calls["count"] == 2 - await client.close() - - -@pytest.mark.asyncio -async def test_api_key_takes_precedence_over_auth_key(): - """When both api_key and auth_key are set, api_key wins and no token is minted.""" - - async def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path != "/api/token", "api_key must not mint a token" - assert request.headers.get("x-authorization") == "my-api-key" - return httpx.Response(200, json={"executionId": "wf-1"}) - - client = _make_client( - handler, - api_key="my-api-key", - auth_key="my-auth-key", - auth_secret="my-auth-secret", - ) - await client.start_agent({"prompt": "test"}) - await client.close() - - -@pytest.mark.asyncio -async def test_token_mint_failure_degrades_to_anonymous(): - """A failing /token endpoint logs a warning and the request proceeds unauthenticated.""" - - async def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/api/token": - return httpx.Response(503, text="token service down") - assert "x-authorization" not in request.headers - return httpx.Response(200, json={"executionId": "wf-1"}) - - client = _make_client(handler, auth_key="key1", auth_secret="secret1") - result = await client.start_agent({"prompt": "test"}) - assert result["executionId"] == "wf-1" - await client.close() diff --git a/tests/unit/ai/test_integration_setup.py b/tests/unit/ai/test_integration_setup.py index a10f9d10..1226856b 100644 --- a/tests/unit/ai/test_integration_setup.py +++ b/tests/unit/ai/test_integration_setup.py @@ -79,10 +79,9 @@ def _make_runtime(self, auto_register=True): from conductor.ai.agents.runtime.runtime import AgentRuntime config = AgentConfig( - server_url="http://localhost:8080/api", auto_register_integrations=auto_register, ) - runtime = AgentRuntime(config=config) + runtime = AgentRuntime(settings=config) return runtime, mock_integration_client def test_upserts_integration_with_api_key(self): @@ -247,10 +246,9 @@ def _make_runtime(self): from conductor.ai.agents.runtime.runtime import AgentRuntime config = AgentConfig( - server_url="http://localhost:8080/api", auto_register_integrations=True, ) - runtime = AgentRuntime(config=config) + runtime = AgentRuntime(settings=config) return runtime, mock_integration_client def test_single_agent(self): @@ -329,10 +327,9 @@ def test_prepare_calls_ensure_when_enabled(self): from conductor.ai.agents.runtime.runtime import AgentRuntime config = AgentConfig( - server_url="http://localhost:8080/api", auto_register_integrations=True, ) - runtime = AgentRuntime(config=config) + runtime = AgentRuntime(settings=config) runtime._ensure_models_for_agent = MagicMock() runtime._compile_agent = MagicMock() @@ -354,10 +351,9 @@ def test_prepare_skips_ensure_when_disabled(self): from conductor.ai.agents.runtime.runtime import AgentRuntime config = AgentConfig( - server_url="http://localhost:8080/api", auto_register_integrations=False, ) - runtime = AgentRuntime(config=config) + runtime = AgentRuntime(settings=config) runtime._ensure_models_for_agent = MagicMock() runtime._compile_agent = MagicMock() diff --git a/tests/unit/ai/test_langgraph_stategraph_example.py b/tests/unit/ai/test_langgraph_stategraph_example.py index b751d2e4..b9817f3b 100644 --- a/tests/unit/ai/test_langgraph_stategraph_example.py +++ b/tests/unit/ai/test_langgraph_stategraph_example.py @@ -76,7 +76,7 @@ def test_associate_templates_does_not_crash_with_graph_sub_agent(self, custom_gr # Directly inject the graph as a sub-agent (bypassing type checks) wrapper.agents = [custom_graph] - config = AgentConfig(server_url="http://localhost:8080") + config = AgentConfig() runtime = AgentRuntime.__new__(AgentRuntime) runtime._config = config runtime._prompt_client_instance = MagicMock() diff --git a/tests/unit/ai/test_passthrough_registration.py b/tests/unit/ai/test_passthrough_registration.py index f9c08439..6c184e6c 100644 --- a/tests/unit/ai/test_passthrough_registration.py +++ b/tests/unit/ai/test_passthrough_registration.py @@ -90,11 +90,7 @@ def test_build_passthrough_func_passes_auth_to_langgraph_worker(self): from conductor.ai.agents.runtime.runtime import AgentRuntime from conductor.ai.agents.runtime.config import AgentConfig - config = AgentConfig( - server_url="http://testserver:8080/api", - auth_key="my_key", - auth_secret="my_secret", - ) + config = AgentConfig() graph = MagicMock() type(graph).__name__ = "CompiledStateGraph" @@ -105,6 +101,12 @@ def test_build_passthrough_func_passes_auth_to_langgraph_worker(self): # verify the full propagation. runtime = AgentRuntime.__new__(AgentRuntime) runtime._config = config + # New contract: connection comes from the Configuration-derived attrs. + from conductor.client.configuration.configuration import Configuration + + runtime._conductor_config = Configuration(server_api_url="http://testserver:8080/api") + runtime._auth_key = "my_key" + runtime._auth_secret = "my_secret" entry = runtime._build_passthrough_func(graph, "langgraph", "test_graph") with patch("conductor.ai.agents.frameworks.langgraph.make_langgraph_worker") as mock_worker: @@ -125,17 +127,19 @@ def test_build_passthrough_func_passes_credentials_to_langgraph_worker(self): from conductor.ai.agents.runtime.runtime import AgentRuntime from conductor.ai.agents.runtime.config import AgentConfig - config = AgentConfig( - server_url="http://testserver:8080/api", - auth_key="my_key", - auth_secret="my_secret", - ) + config = AgentConfig() graph = MagicMock() type(graph).__name__ = "CompiledStateGraph" runtime = AgentRuntime.__new__(AgentRuntime) runtime._config = config + # New contract: connection comes from the Configuration-derived attrs. + from conductor.client.configuration.configuration import Configuration + + runtime._conductor_config = Configuration(server_api_url="http://testserver:8080/api") + runtime._auth_key = "my_key" + runtime._auth_secret = "my_secret" entry = runtime._build_passthrough_func( graph, "langgraph", @@ -160,17 +164,19 @@ def test_build_passthrough_func_passes_auth_to_claude_agent_sdk_worker(self): from conductor.ai.agents.runtime.runtime import AgentRuntime from conductor.ai.agents.runtime.config import AgentConfig - config = AgentConfig( - server_url="http://testserver:8080/api", - auth_key="my_key", - auth_secret="my_secret", - ) + config = AgentConfig() claude_sdk = pytest.importorskip("claude_code_sdk") options = claude_sdk.ClaudeCodeOptions(system_prompt="hello", max_turns=4) runtime = AgentRuntime.__new__(AgentRuntime) runtime._config = config + # New contract: connection comes from the Configuration-derived attrs. + from conductor.client.configuration.configuration import Configuration + + runtime._conductor_config = Configuration(server_api_url="http://testserver:8080/api") + runtime._auth_key = "my_key" + runtime._auth_secret = "my_secret" # Options travel as a plain-config dict inside a PassthroughWorkerEntry # (ClaudeCodeOptions is never picklable as-is — debug_stderr) and are # rebuilt in the worker process; invoke the entry to verify the chain. @@ -195,15 +201,16 @@ def test_build_passthrough_func_passes_auth_to_claude_agent_sdk_worker(self): assert mock_worker.call_args.kwargs == {"credential_names": None} -def _make_fake_task(workflow_instance_id="wf-123", prompt="test prompt"): - """Build a minimal Conductor-like Task object for passthrough worker tests.""" +def _make_fake_task(workflow_instance_id="wf-123", prompt="test prompt", runtime_metadata=None): + """Build a minimal Conductor-like Task object for passthrough worker tests. + + The host delivers resolved secrets on ``Task.runtimeMetadata`` (wire-only). + """ task = MagicMock() task.workflow_instance_id = workflow_instance_id task.task_id = "task-abc" - task.input_data = { - "prompt": prompt, - "__agentspan_ctx__": {"execution_token": "tok-fake"}, - } + task.input_data = {"prompt": prompt} + task.runtime_metadata = runtime_metadata or {} return task @@ -215,13 +222,10 @@ class TestLangchainWorkerCredentialInjection: reason="langchain_core not installed", ) - # _get_credential_fetcher is imported from _dispatch inside the closure, - # so we patch it at the source module. - _FETCHER_PATCH = "conductor.ai.agents.runtime._dispatch._get_credential_fetcher" - def test_closure_credentials_injected_into_environ(self): - """When credential_names are passed, the worker resolves and injects them - into os.environ before calling executor.invoke(), and cleans up after.""" + """When credential_names are passed, the worker reads the host-delivered + values off Task.runtimeMetadata and injects them into os.environ before + calling executor.invoke(), and cleans up after.""" from conductor.ai.agents.frameworks.langchain import make_langchain_worker captured_env = {} @@ -243,13 +247,8 @@ def fake_invoke(input_dict, **kwargs): credential_names=["GITHUB_TOKEN"], ) - fake_fetcher = MagicMock() - fake_fetcher.fetch.return_value = {"GITHUB_TOKEN": "ghp_test123"} - - task = _make_fake_task() - - with patch(self._FETCHER_PATCH, return_value=fake_fetcher): - result = worker_fn(task) + task = _make_fake_task(runtime_metadata={"GITHUB_TOKEN": "ghp_test123"}) + result = worker_fn(task) # The executor saw the credential during invocation assert captured_env["GITHUB_TOKEN"] == "ghp_test123" @@ -257,8 +256,6 @@ def fake_invoke(input_dict, **kwargs): assert "GITHUB_TOKEN" not in os.environ # Task completed successfully assert result.status.name == "COMPLETED" - # Fetcher was called with the closure credential names - fake_fetcher.fetch.assert_called_once_with("tok-fake", ["GITHUB_TOKEN"]) def test_closure_credentials_used_even_when_workflow_registry_empty(self): """The closure path works even if _workflow_credentials has no entry for @@ -291,21 +288,17 @@ def fake_invoke(input_dict, **kwargs): credential_names=["MY_SECRET"], ) - fake_fetcher = MagicMock() - fake_fetcher.fetch.return_value = {"MY_SECRET": "s3cr3t"} - task = _make_fake_task() - - with patch(self._FETCHER_PATCH, return_value=fake_fetcher): - result = worker_fn(task) + task = _make_fake_task(runtime_metadata={"MY_SECRET": "s3cr3t"}) + result = worker_fn(task) # Even with empty _workflow_credentials, the closure names were used assert captured_env["MY_SECRET"] == "s3cr3t" assert "MY_SECRET" not in os.environ assert result.status.name == "COMPLETED" - def test_no_credentials_means_no_fetch(self): + def test_no_credentials_means_no_injection(self): """When credential_names is None/empty and _workflow_credentials is empty, - no credential fetch is attempted.""" + the worker runs without touching credentials.""" from conductor.ai.agents.frameworks.langchain import make_langchain_worker from conductor.ai.agents.runtime._dispatch import ( _workflow_credentials, @@ -329,12 +322,8 @@ def test_no_credentials_means_no_fetch(self): ) task = _make_fake_task() + result = worker_fn(task) - with patch(self._FETCHER_PATCH) as mock_get_fetcher: - result = worker_fn(task) - - # Fetcher factory should never be called — no credentials requested - mock_get_fetcher.assert_not_called() assert result.status.name == "COMPLETED" # Full extraction path tests moved to test_credential_injection_integration.py diff --git a/tests/unit/ai/test_run.py b/tests/unit/ai/test_run.py index 50191e05..fa60673a 100644 --- a/tests/unit/ai/test_run.py +++ b/tests/unit/ai/test_run.py @@ -22,9 +22,11 @@ def _reset_singleton(): mod = _get_run_module() mod._default_runtime = None mod._default_config = None + mod._default_configuration = None yield mod._default_runtime = None mod._default_config = None + mod._default_configuration = None class TestRunFunction: @@ -171,20 +173,29 @@ def test_configure_stores_config(self): from conductor.ai.agents.run import configure from conductor.ai.agents.runtime.config import AgentConfig - config = AgentConfig(server_url="https://prod:8080/api", auto_start_server=False) + config = AgentConfig(worker_thread_count=4) configure(config=config) mod = _get_run_module() assert mod._default_config is config + def test_configure_stores_configuration(self): + from conductor.ai.agents.run import configure + from conductor.client.configuration.configuration import Configuration + + configuration = Configuration(server_api_url="https://prod:8080/api") + configure(configuration=configuration) + + mod = _get_run_module() + assert mod._default_configuration is configuration + def test_configure_kwargs_override_env(self): from conductor.ai.agents.run import configure - configure(server_url="https://custom:9090/api", auto_start_server=False) + configure(worker_thread_count=7) mod = _get_run_module() - assert mod._default_config.server_url == "https://custom:9090/api" - assert mod._default_config.auto_start_server is False + assert mod._default_config.worker_thread_count == 7 def test_configure_raises_if_runtime_exists(self): mod = _get_run_module() @@ -193,7 +204,7 @@ def test_configure_raises_if_runtime_exists(self): from conductor.ai.agents.run import configure with pytest.raises(RuntimeError, match="configure.*must be called before"): - configure(auto_start_server=False) + configure(worker_thread_count=7) def test_configure_raises_for_unknown_field(self): from conductor.ai.agents.run import configure @@ -204,7 +215,7 @@ def test_configure_raises_for_unknown_field(self): def test_shutdown_preserves_config(self): from conductor.ai.agents.run import configure, shutdown - configure(auto_start_server=False) + configure(worker_thread_count=7) mod = _get_run_module() mod._default_runtime = MagicMock() @@ -212,7 +223,7 @@ def test_shutdown_preserves_config(self): assert mod._default_runtime is None assert mod._default_config is not None - assert mod._default_config.auto_start_server is False + assert mod._default_config.worker_thread_count == 7 class TestDeployFunction: diff --git a/tests/unit/ai/test_runtime.py b/tests/unit/ai/test_runtime.py index 57a88f1f..b9f2d038 100644 --- a/tests/unit/ai/test_runtime.py +++ b/tests/unit/ai/test_runtime.py @@ -16,24 +16,7 @@ from conductor.ai.agents.agent import Agent from conductor.ai.agents.result import AgentStatus, EventType - - -def _mock_requests_post(response_json=None, status_code=200): - """Create a mock for requests.post that returns a fake Response.""" - mock_resp = MagicMock() - mock_resp.status_code = status_code - mock_resp.json.return_value = response_json or {} - mock_resp.raise_for_status.return_value = None - return MagicMock(return_value=mock_resp) - - -def _mock_requests_get(response_json=None, status_code=200): - """Create a mock for requests.get that returns a fake Response.""" - mock_resp = MagicMock() - mock_resp.status_code = status_code - mock_resp.json.return_value = response_json or {} - mock_resp.raise_for_status.return_value = None - return MagicMock(return_value=mock_resp) +from conductor.ai.agents.run_settings import RunSettings class MockWorkflowRun: @@ -64,8 +47,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_extract_simple_output(self, runtime): agent = Agent(name="test", model="openai/gpt-4o") @@ -102,8 +85,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_simple_handoff(self, runtime): result = {"agent_a": "Answer from A", "agent_b": None} @@ -138,8 +121,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_extracts_from_variables(self, runtime): msgs = [{"role": "user", "message": "Hi"}] @@ -222,29 +205,26 @@ def test_singleton_returns_same_instance(self): with patch("conductor.client.orkes_clients.OrkesClients"): with patch("conductor.ai.agents.runtime.worker_manager.TaskHandler", create=True): - with patch("conductor.ai.agents.runtime.server.ensure_server_running"): - rt1 = _get_default_runtime() - rt2 = _get_default_runtime() - assert rt1 is rt2 + rt1 = _get_default_runtime() + rt2 = _get_default_runtime() + assert rt1 is rt2 # Cleanup run_module._default_runtime = None class TestAgentRuntimeInit: - """Test AgentRuntime constructor signature and resolution logic.""" + """Test AgentRuntime constructor: Configuration for server config, + AgentConfig settings for runtime behaviour only.""" def test_no_args_falls_back_to_env(self): - """AgentRuntime() with no args loads config from environment.""" + """AgentRuntime() resolves the server via Configuration's env fallback + (CONDUCTOR_SERVER_URL → AGENTSPAN_SERVER_URL).""" import os - env_backup = {} - for key in ["AGENTSPAN_SERVER_URL", "AGENTSPAN_AUTH_KEY", "AGENTSPAN_AUTH_SECRET"]: - env_backup[key] = os.environ.pop(key, None) - + keys = ["CONDUCTOR_SERVER_URL", "AGENTSPAN_SERVER_URL"] + env_backup = {k: os.environ.pop(k, None) for k in keys} os.environ["AGENTSPAN_SERVER_URL"] = "http://env-server/api" - os.environ["AGENTSPAN_AUTH_KEY"] = "env-key" - os.environ["AGENTSPAN_AUTH_SECRET"] = "env-secret" try: with patch("conductor.client.orkes_clients.OrkesClients"): @@ -252,80 +232,63 @@ def test_no_args_falls_back_to_env(self): from conductor.ai.agents.runtime.runtime import AgentRuntime rt = AgentRuntime() - assert rt._config.server_url == "http://env-server/api" - assert rt._config.auth_key == "env-key" - assert rt._config.auth_secret == "env-secret" + assert rt._conductor_config.host == "http://env-server/api" finally: - for key in ["AGENTSPAN_SERVER_URL", "AGENTSPAN_AUTH_KEY", "AGENTSPAN_AUTH_SECRET"]: + for key in keys: os.environ.pop(key, None) for key, val in env_backup.items(): if val is not None: os.environ[key] = val - def test_explicit_params(self): - """AgentRuntime(server_url=..., api_key=..., api_secret=...) uses explicit values.""" + def test_explicit_configuration(self): + """AgentRuntime(Configuration(...)) uses the given Configuration verbatim.""" with patch("conductor.client.orkes_clients.OrkesClients"): with patch("conductor.ai.agents.runtime.worker_manager.TaskHandler", create=True): - from conductor.ai.agents.runtime.runtime import AgentRuntime - - rt = AgentRuntime( - server_url="http://explicit/api", - api_key="explicit-key", - api_secret="explicit-secret", + from conductor.client.configuration.configuration import Configuration + from conductor.client.configuration.settings.authentication_settings import ( + AuthenticationSettings, ) - assert rt._config.server_url == "http://explicit/api" - assert rt._config.api_key == "explicit-key" - assert rt._config.auth_secret == "explicit-secret" - - def test_config_object(self): - """AgentRuntime(config=AgentConfig(...)) uses the config object.""" - with patch("conductor.client.orkes_clients.OrkesClients"): - with patch("conductor.ai.agents.runtime.worker_manager.TaskHandler", create=True): - from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - cfg = AgentConfig( - server_url="http://config/api", - auth_key="config-key", - auth_secret="config-secret", + configuration = Configuration( + server_api_url="http://explicit/api", + authentication_settings=AuthenticationSettings( + key_id="explicit-key", key_secret="explicit-secret" + ), ) - rt = AgentRuntime(config=cfg) - assert rt._config.server_url == "http://config/api" - assert rt._config.auth_key == "config-key" - assert rt._config.auth_secret == "config-secret" - - def test_explicit_overrides_config(self): - """Explicit params take precedence over config object values.""" + rt = AgentRuntime(configuration) + assert rt._conductor_config is configuration + assert rt._conductor_config.host == "http://explicit/api" + assert rt._auth_key == "explicit-key" + assert rt._auth_secret == "explicit-secret" + + def test_settings_carries_behaviour_knobs(self): + """AgentRuntime(settings=AgentConfig(...)) applies runtime behaviour knobs.""" with patch("conductor.client.orkes_clients.OrkesClients"): with patch("conductor.ai.agents.runtime.worker_manager.TaskHandler", create=True): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - cfg = AgentConfig( - server_url="http://config/api", - auth_key="config-key", - auth_secret="config-secret", - ) - rt = AgentRuntime(config=cfg, server_url="http://override/api") - assert rt._config.server_url == "http://override/api" - # Non-overridden values come from config - assert rt._config.auth_key == "config-key" - assert rt._config.auth_secret == "config-secret" - - def test_config_preserves_tuning_knobs(self): - """Tuning knobs from config are preserved when using explicit connection params.""" + cfg = AgentConfig(worker_thread_count=4, auto_start_workers=False) + rt = AgentRuntime(settings=cfg) + assert rt._config.worker_thread_count == 4 + assert rt._config.auto_start_workers is False + + def test_configuration_is_single_source_of_server_config(self): + """Connection fields on settings are ignored — Configuration wins.""" with patch("conductor.client.orkes_clients.OrkesClients"): with patch("conductor.ai.agents.runtime.worker_manager.TaskHandler", create=True): + from conductor.client.configuration.configuration import Configuration from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - cfg = AgentConfig( - server_url="http://config/api", - worker_thread_count=4, + cfg = AgentConfig(auto_start_workers=False) + rt = AgentRuntime( + Configuration(server_api_url="http://wins/api"), settings=cfg ) - rt = AgentRuntime(config=cfg, server_url="http://override/api") - assert rt._config.server_url == "http://override/api" - assert rt._config.worker_thread_count == 4 + # Connection/auth come solely from the Configuration; AgentConfig + # (settings) carries no server/auth fields to leak into the client. + assert rt._conductor_config.host == "http://wins/api" class TestAgentConfig: @@ -335,9 +298,9 @@ def test_defaults(self): from conductor.ai.agents.runtime.config import AgentConfig config = AgentConfig() - assert config.server_url == "http://localhost:8080/api" - assert config.llm_retry_count == 3 assert config.worker_poll_interval_ms == 100 + assert config.worker_thread_count == 1 + assert config.liveness_enabled is True def test_env_override(self): from unittest.mock import patch @@ -345,16 +308,10 @@ def test_env_override(self): from conductor.ai.agents.runtime.config import AgentConfig with patch.dict( - "os.environ", {"AGENTSPAN_SERVER_URL": "http://custom:9090/api"}, clear=True + "os.environ", {"AGENTSPAN_WORKER_THREADS": "4"}, clear=True ): config = AgentConfig.from_env() - assert config.server_url == "http://custom:9090/api" - - def test_custom_retry_count(self): - from conductor.ai.agents.runtime.config import AgentConfig - - config = AgentConfig(llm_retry_count=5) - assert config.llm_retry_count == 5 + assert config.worker_thread_count == 4 class TestCorrelationId: @@ -367,8 +324,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_run_generates_correlation_id(self, runtime): """Verify AgentResult.correlation_id is a valid UUID string.""" @@ -418,18 +375,14 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_respond_calls_server_api(self, runtime): - mock_post = _mock_requests_post() - with patch("requests.post", mock_post): - runtime.respond("wf-123", {"approved": True}) + runtime._agent_client.respond = MagicMock(return_value=None) + runtime.respond("wf-123", {"approved": True}) - mock_post.assert_called_once() - call_args = mock_post.call_args - assert "/wf-123/respond" in call_args[0][0] - assert call_args[1]["json"] == {"approved": True} + runtime._agent_client.respond.assert_called_once_with("wf-123", {"approved": True}) def test_approve_delegates_to_respond(self, runtime): runtime.respond = MagicMock() @@ -452,8 +405,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_run_passes_media_to_start_via_server(self, runtime): """Verify media URLs are passed to _start_via_server.""" @@ -535,8 +488,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_shutdown_stops_workers(self, runtime): runtime._workers_started = True @@ -580,8 +533,8 @@ def test_context_manager(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - rt = AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + rt = AgentRuntime(settings=config) rt._workers_started = True rt._worker_manager = MagicMock() @@ -604,8 +557,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_no_tools_no_agents(self, runtime): agent = Agent(name="simple", model="openai/gpt-4o") @@ -800,8 +753,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_single_llm_task(self, runtime): with patch.object( @@ -831,16 +784,15 @@ def test_multiple_llm_tasks(self, runtime): assert usage.total_tokens == 450 def test_no_llm_tasks(self, runtime): - task = MagicMock() - task.task_type = "SIMPLE" - task.output_data = {} - wf_run = MockWorkflowRun(tasks=[task]) - - assert runtime._extract_token_usage(wf_run) is None + # Execution tree has no LLM tasks / no server-computed token usage. + runtime._agent_client.get_execution = MagicMock( + return_value={"tasks": [{"taskType": "SIMPLE"}]} + ) + assert runtime._extract_token_usage("wf-123") is None def test_no_tasks(self, runtime): - wf_run = MockWorkflowRun(tasks=[]) - assert runtime._extract_token_usage(wf_run) is None + runtime._agent_client.get_execution = MagicMock(return_value={"tasks": []}) + assert runtime._extract_token_usage("wf-123") is None def test_computes_total_when_missing(self, runtime): with patch.object( @@ -865,8 +817,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_extracts_tool_tasks(self, runtime): task = MagicMock() @@ -905,15 +857,15 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def _mock_status_response(self, runtime, response_json): - """Patch requests.get to return a mock status response.""" - return patch( - "requests.get", - _mock_requests_get(response_json), - ) + """Drive the agent client's get_status to return a mock status dict.""" + from contextlib import nullcontext + + runtime._agent_client.get_status = MagicMock(return_value=response_json) + return nullcontext() def test_completed(self, runtime): resp = { @@ -994,8 +946,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_plan_returns_raw_server_response(self, runtime): agent = Agent(name="test", model="openai/gpt-4o") @@ -1004,10 +956,9 @@ def test_plan_returns_raw_server_response(self, runtime): "requiredWorkers": [], } - # plan() POSTs directly to /agent/compile and returns the raw response - mock_post = _mock_requests_post(server_response) - with patch("requests.post", mock_post): - result = runtime.plan(agent) + # plan() calls compile_agent and returns the raw response + runtime._agent_client.compile_agent = MagicMock(return_value=server_response) + result = runtime.plan(agent) assert "workflowDef" in result assert result["workflowDef"]["name"] == "test_wf" @@ -1017,12 +968,12 @@ def test_compile_via_server_wraps_config_in_start_request(self, runtime): """_compile_via_server sends agentConfig wrapped in a StartRequest payload.""" agent = Agent(name="test", model="openai/gpt-4o", instructions="Be helpful.") - mock_post = _mock_requests_post({"workflowDef": {"name": "test", "tasks": []}}) - with patch("requests.post", mock_post): - runtime._compile_via_server(agent) + runtime._agent_client.compile_agent = MagicMock( + return_value={"workflowDef": {"name": "test", "tasks": []}} + ) + runtime._compile_via_server(agent) - call_kwargs = mock_post.call_args - payload = call_kwargs[1]["json"] + payload = runtime._agent_client.compile_agent.call_args[0][0] # The payload must wrap the config in {"agentConfig": ...} assert "agentConfig" in payload assert payload["agentConfig"]["name"] == "test" @@ -1042,8 +993,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def _setup_run(self, runtime, output="Hello", status="COMPLETED"): runtime._prepare_workers = MagicMock() @@ -1263,8 +1214,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_run_rejects_blank_input_without_media_or_context(self, runtime): agent = Agent(name="test", model="openai/gpt-4o") @@ -1308,8 +1259,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_run_populates_tool_calls(self, runtime): """run() fetches workflow execution and populates tool_calls.""" @@ -1378,6 +1329,9 @@ def test_run_without_tool_calls(self, runtime): wf.variables = {} runtime._workflow_client.get_workflow = MagicMock(return_value=wf) + # No LLM tasks in the execution tree -> no token usage. + runtime._agent_client.get_execution = MagicMock(return_value={"tasks": []}) + result = runtime.run(agent, "Hi") assert result.tool_calls == [] @@ -1440,8 +1394,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - rt = AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + rt = AgentRuntime(settings=config) yield rt def test_regex_guardrail_only_no_workers(self, runtime): @@ -1526,8 +1480,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_stream_yields_done_on_completion(self, runtime): agent = Agent(name="test", model="openai/gpt-4o") @@ -1536,20 +1490,14 @@ def test_stream_yields_done_on_completion(self, runtime): runtime._prepare_workers = MagicMock() runtime._start_via_server = MagicMock(return_value=("wf-stream-1", None, [])) - # Mock get_workflow to return completed on first poll - completed_wf = MagicMock() - completed_wf.status = "COMPLETED" - completed_wf.tasks = [] - completed_wf.output = {"result": "Final answer"} - runtime._workflow_client.get_workflow = MagicMock(return_value=completed_wf) + runtime._agent_client.stream_sse = MagicMock( + return_value=iter([{"event": "done", "data": {"output": "Final answer"}}]) + ) events = list(runtime.stream(agent, "Hello")) done_events = [e for e in events if e.type == EventType.DONE] assert len(done_events) == 1 assert done_events[0].output == "Final answer" - runtime._workflow_client.get_workflow.assert_called_once_with( - "wf-stream-1", include_tasks=True - ) def test_stream_yields_thinking_event(self, runtime): agent = Agent(name="test", model="openai/gpt-4o") @@ -1557,27 +1505,16 @@ def test_stream_yields_thinking_event(self, runtime): runtime._prepare_workers = MagicMock() runtime._start_via_server = MagicMock(return_value=("wf-stream-2", None, [])) - # First poll: LLM task running - running_wf = MagicMock() - running_wf.status = "RUNNING" - llm_task = MagicMock() - llm_task.task_id = "t1" - llm_task.task_type = "LLM_CHAT_COMPLETE" - llm_task.reference_task_name = "test_llm" - llm_task.status = "IN_PROGRESS" - llm_task.output_data = {} - running_wf.tasks = [llm_task] - - # Second poll: completed - completed_wf = MagicMock() - completed_wf.status = "COMPLETED" - completed_wf.tasks = [llm_task] - completed_wf.output = {"result": "done"} - - runtime._workflow_client.get_workflow = MagicMock(side_effect=[running_wf, completed_wf]) + runtime._agent_client.stream_sse = MagicMock( + return_value=iter( + [ + {"event": "thinking", "data": {"content": "let me think"}}, + {"event": "done", "data": {"output": "done"}}, + ] + ) + ) - with patch("time.sleep"): - events = list(runtime.stream(agent, "Hello")) + events = list(runtime.stream(agent, "Hello")) thinking = [e for e in events if e.type == EventType.THINKING] assert len(thinking) == 1 @@ -1588,11 +1525,9 @@ def test_stream_yields_error_on_failure(self, runtime): runtime._prepare_workers = MagicMock() runtime._start_via_server = MagicMock(return_value=("wf-stream-err", None, [])) - failed_wf = MagicMock() - failed_wf.status = "FAILED" - failed_wf.tasks = [] - failed_wf.output = None - runtime._workflow_client.get_workflow = MagicMock(return_value=failed_wf) + runtime._agent_client.stream_sse = MagicMock( + return_value=iter([{"event": "error", "data": {"content": "Workflow FAILED"}}]) + ) events = list(runtime.stream(agent, "Hello")) error_events = [e for e in events if e.type == EventType.ERROR] @@ -1605,21 +1540,16 @@ def test_stream_yields_waiting_on_paused(self, runtime): runtime._prepare_workers = MagicMock() runtime._start_via_server = MagicMock(return_value=("wf-stream-wait", None, [])) - # First poll: paused - paused_wf = MagicMock() - paused_wf.status = "PAUSED" - paused_wf.tasks = [] - - # Second poll: completed - completed_wf = MagicMock() - completed_wf.status = "COMPLETED" - completed_wf.tasks = [] - completed_wf.output = {"result": "resumed"} - - runtime._workflow_client.get_workflow = MagicMock(side_effect=[paused_wf, completed_wf]) + runtime._agent_client.stream_sse = MagicMock( + return_value=iter( + [ + {"event": "waiting", "data": {}}, + {"event": "done", "data": {"output": "resumed"}}, + ] + ) + ) - with patch("time.sleep"): - events = list(runtime.stream(agent, "Hello")) + events = list(runtime.stream(agent, "Hello")) waiting = [e for e in events if e.type == EventType.WAITING] assert len(waiting) == 1 @@ -1630,8 +1560,8 @@ def test_stream_yields_error_on_fetch_exception(self, runtime): runtime._prepare_workers = MagicMock() runtime._start_via_server = MagicMock(return_value=("wf-stream-exc", None, [])) - runtime._workflow_client.get_workflow = MagicMock( - side_effect=RuntimeError("connection lost") + runtime._agent_client.stream_sse = MagicMock( + return_value=iter([{"event": "error", "data": {"content": "connection lost"}}]) ) events = list(runtime.stream(agent, "Hello")) @@ -1645,24 +1575,21 @@ def test_stream_yields_tool_call_and_result(self, runtime): runtime._prepare_workers = MagicMock() runtime._start_via_server = MagicMock(return_value=("wf-stream-tool", None, [])) - # Create a dispatch task with function field - dispatch_task = MagicMock() - dispatch_task.task_id = "t-dispatch" - dispatch_task.task_type = "SIMPLE" - dispatch_task.reference_task_name = "test_dispatch" - dispatch_task.status = "COMPLETED" - dispatch_task.output_data = { - "function": "get_weather", - "parameters": {"city": "NYC"}, - "result": "72F", - } - - completed_wf = MagicMock() - completed_wf.status = "COMPLETED" - completed_wf.tasks = [dispatch_task] - completed_wf.output = {"result": "It's 72F in NYC"} - - runtime._workflow_client.get_workflow = MagicMock(return_value=completed_wf) + runtime._agent_client.stream_sse = MagicMock( + return_value=iter( + [ + { + "event": "tool_call", + "data": {"toolName": "get_weather", "args": {"city": "NYC"}}, + }, + { + "event": "tool_result", + "data": {"toolName": "get_weather", "result": "72F"}, + }, + {"event": "done", "data": {"output": "It's 72F in NYC"}}, + ] + ) + ) events = list(runtime.stream(agent, "Hello")) tool_calls = [e for e in events if e.type == EventType.TOOL_CALL] @@ -1677,19 +1604,14 @@ def test_stream_yields_handoff(self, runtime): runtime._prepare_workers = MagicMock() runtime._start_via_server = MagicMock(return_value=("wf-stream-handoff", None, [])) - sub_task = MagicMock() - sub_task.task_id = "t-sub" - sub_task.task_type = "SUB_WORKFLOW" - sub_task.reference_task_name = "test_handoff_agent_b" - sub_task.status = "IN_PROGRESS" - sub_task.output_data = {} - - completed_wf = MagicMock() - completed_wf.status = "COMPLETED" - completed_wf.tasks = [sub_task] - completed_wf.output = {"result": "answer from b"} - - runtime._workflow_client.get_workflow = MagicMock(return_value=completed_wf) + runtime._agent_client.stream_sse = MagicMock( + return_value=iter( + [ + {"event": "handoff", "data": {"target": "agent_b"}}, + {"event": "done", "data": {"output": "answer from b"}}, + ] + ) + ) events = list(runtime.stream(agent, "Hello")) handoffs = [e for e in events if e.type == EventType.HANDOFF] @@ -1710,8 +1632,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_extract_output_with_output_type_from_dict(self, runtime): from dataclasses import dataclass @@ -1781,17 +1703,15 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_non_dict_token_usage_skipped(self, runtime): - """Non-dict tokenUsed value should be skipped.""" - task = MagicMock() - task.task_type = "LLM_CHAT_COMPLETE" - task.output_data = {"tokenUsed": "not a dict"} - wf_run = MockWorkflowRun(tasks=[task]) - - assert runtime._extract_token_usage(wf_run) is None + """Missing/empty token usage should yield no TokenUsage.""" + runtime._agent_client.get_execution = MagicMock( + return_value={"tokenUsage": {}, "tasks": []} + ) + assert runtime._extract_token_usage("wf-123") is None # ── get_status edge cases ───────────────────────────────────────────── @@ -1807,8 +1727,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_completed_non_dict_output(self, runtime): """Non-dict output in completed workflow is returned as-is.""" @@ -1819,11 +1739,8 @@ def test_completed_non_dict_output(self, runtime): "isWaiting": False, "output": "raw output", } - with patch( - "requests.get", - _mock_requests_get(resp), - ): - status = runtime.get_status("wf-1") + runtime._agent_client.get_status = MagicMock(return_value=resp) + status = runtime.get_status("wf-1") assert status.is_complete is True assert status.output == "raw output" @@ -1841,8 +1758,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_with_handoffs(self, runtime): from conductor.ai.agents.handoff import OnTextMention @@ -1876,15 +1793,15 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080") - return AgentRuntime(config=config) + config = AgentConfig() + return AgentRuntime(settings=config) def test_start_via_server_returns_execution_id(self, runtime): """_start_via_server returns (executionId, requiredWorkers) tuple.""" agent = Agent(name="test", model="openai/gpt-4o") - with patch("requests.post", _mock_requests_post({"executionId": "wf-server-1"})): - exec_id, required_workers, _ = runtime._start_via_server(agent, "hello") + runtime._agent_client.start_agent = MagicMock(return_value={"executionId": "wf-server-1"}) + exec_id, required_workers, _ = runtime._start_via_server(agent, "hello") assert exec_id == "wf-server-1" assert required_workers is None @@ -1894,8 +1811,8 @@ def test_start_via_server_returns_required_workers(self, runtime): agent = Agent(name="test", model="openai/gpt-4o") resp = {"executionId": "wf-server-2", "requiredWorkers": ["agent_termination", "my_tool"]} - with patch("requests.post", _mock_requests_post(resp)): - exec_id, required_workers, _ = runtime._start_via_server(agent, "hello") + runtime._agent_client.start_agent = MagicMock(return_value=resp) + exec_id, required_workers, _ = runtime._start_via_server(agent, "hello") assert exec_id == "wf-server-2" assert required_workers == {"agent_termination", "my_tool"} @@ -1904,60 +1821,50 @@ def test_start_via_server_sends_prompt(self, runtime): """_start_via_server includes the prompt in the payload.""" agent = Agent(name="test", model="openai/gpt-4o") - mock_post = _mock_requests_post({"executionId": "wf-1"}) - with patch("requests.post", mock_post): - runtime._start_via_server(agent, "test prompt") + runtime._agent_client.start_agent = MagicMock(return_value={"executionId": "wf-1"}) + runtime._start_via_server(agent, "test prompt") - call_kwargs = mock_post.call_args - payload = call_kwargs[1]["json"] + payload = runtime._agent_client.start_agent.call_args[0][0] assert payload["prompt"] == "test prompt" def test_start_via_server_passes_media(self, runtime): """_start_via_server includes media in the payload.""" agent = Agent(name="test", model="openai/gpt-4o") - mock_post = _mock_requests_post({"executionId": "wf-1"}) - with patch("requests.post", mock_post): - runtime._start_via_server(agent, "describe", media=["https://img.png"]) + runtime._agent_client.start_agent = MagicMock(return_value={"executionId": "wf-1"}) + runtime._start_via_server(agent, "describe", media=["https://img.png"]) - call_kwargs = mock_post.call_args - payload = call_kwargs[1]["json"] + payload = runtime._agent_client.start_agent.call_args[0][0] assert payload["media"] == ["https://img.png"] def test_start_via_server_passes_context(self, runtime): """_start_via_server includes context in the payload.""" agent = Agent(name="test", model="openai/gpt-4o") - mock_post = _mock_requests_post({"executionId": "wf-1"}) - with patch("requests.post", mock_post): - runtime._start_via_server(agent, "describe", context={"repo": "acme"}) + runtime._agent_client.start_agent = MagicMock(return_value={"executionId": "wf-1"}) + runtime._start_via_server(agent, "describe", context={"repo": "acme"}) - call_kwargs = mock_post.call_args - payload = call_kwargs[1]["json"] + payload = runtime._agent_client.start_agent.call_args[0][0] assert payload["context"] == {"repo": "acme"} def test_start_via_server_passes_idempotency_key(self, runtime): """Idempotency key is included in the payload when provided.""" agent = Agent(name="test", model="openai/gpt-4o") - mock_post = _mock_requests_post({"executionId": "wf-1"}) - with patch("requests.post", mock_post): - runtime._start_via_server(agent, "hi", idempotency_key="idem-123") + runtime._agent_client.start_agent = MagicMock(return_value={"executionId": "wf-1"}) + runtime._start_via_server(agent, "hi", idempotency_key="idem-123") - call_kwargs = mock_post.call_args - payload = call_kwargs[1]["json"] + payload = runtime._agent_client.start_agent.call_args[0][0] assert payload["idempotencyKey"] == "idem-123" def test_start_via_server_omits_idempotency_key_when_none(self, runtime): """Idempotency key is not in the payload when not provided.""" agent = Agent(name="test", model="openai/gpt-4o") - mock_post = _mock_requests_post({"executionId": "wf-1"}) - with patch("requests.post", mock_post): - runtime._start_via_server(agent, "hi") + runtime._agent_client.start_agent = MagicMock(return_value={"executionId": "wf-1"}) + runtime._start_via_server(agent, "hi") - call_kwargs = mock_post.call_args - payload = call_kwargs[1]["json"] + payload = runtime._agent_client.start_agent.call_args[0][0] assert "idempotencyKey" not in payload @@ -1971,35 +1878,33 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080") - return AgentRuntime(config=config) + config = AgentConfig() + return AgentRuntime(settings=config) def test_start_framework_via_server_passes_credentials(self, runtime): """Framework start payload includes request-level credentials.""" - mock_post = _mock_requests_post({"executionId": "wf-fw-1"}) - with patch("requests.post", mock_post): - runtime._start_framework_via_server( - framework="openai", - raw_config={"name": "fw_agent"}, - prompt="hello", - credentials=["OPENAI_API_KEY"], - ) + runtime._agent_client.start_agent = MagicMock(return_value={"executionId": "wf-fw-1"}) + runtime._start_framework_via_server( + framework="openai", + raw_config={"name": "fw_agent"}, + prompt="hello", + credentials=["OPENAI_API_KEY"], + ) - payload = mock_post.call_args[1]["json"] + payload = runtime._agent_client.start_agent.call_args[0][0] assert payload["credentials"] == ["OPENAI_API_KEY"] def test_start_framework_via_server_passes_context(self, runtime): """Framework start payload includes context.""" - mock_post = _mock_requests_post({"executionId": "wf-fw-1"}) - with patch("requests.post", mock_post): - runtime._start_framework_via_server( - framework="openai", - raw_config={"name": "fw_agent"}, - prompt="hello", - context={"repo": "acme"}, - ) + runtime._agent_client.start_agent = MagicMock(return_value={"executionId": "wf-fw-1"}) + runtime._start_framework_via_server( + framework="openai", + raw_config={"name": "fw_agent"}, + prompt="hello", + context={"repo": "acme"}, + ) - payload = mock_post.call_args[1]["json"] + payload = runtime._agent_client.start_agent.call_args[0][0] assert payload["context"] == {"repo": "acme"} @@ -2013,8 +1918,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080") - return AgentRuntime(config=config) + config = AgentConfig() + return AgentRuntime(settings=config) def test_run_framework_registers_and_clears_workflow_credentials(self, runtime): """Framework run() exposes request credentials to extracted tools for the run lifetime.""" @@ -2073,8 +1978,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080") - return AgentRuntime(config=config) + config = AgentConfig() + return AgentRuntime(settings=config) @patch("conductor.ai.agents.runtime.runtime.time.sleep", return_value=None) def test_returns_on_completed(self, mock_sleep, runtime): @@ -2198,8 +2103,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - rt = AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + rt = AgentRuntime(settings=config) return rt, mock_prompt_client def test_string_passthrough(self, runtime): @@ -2283,8 +2188,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - rt = AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + rt = AgentRuntime(settings=config) return rt, mock_prompt_client def test_associates_template_with_model(self, runtime): @@ -2396,8 +2301,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_rejected_finish_reason(self, runtime): """COMPLETED with finishReason=rejected maps to FinishReason.REJECTED.""" @@ -2431,8 +2336,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_rejected_output_preserved(self, runtime): """Rejection output with finishReason=rejected is kept as-is.""" @@ -2471,8 +2376,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_extracts_sub_results_from_server_output(self, runtime): output = {"result": "joined text", "subResults": {"a": "X", "b": "Y"}} @@ -2623,29 +2528,27 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080") - return AgentRuntime(config=config) + config = AgentConfig() + return AgentRuntime(settings=config) def test_timeout_included_in_start_payload(self, runtime): """run(timeout=5) sends timeoutSeconds: 5 in the start payload.""" agent = Agent(name="test", model="openai/gpt-4o") - mock_post = _mock_requests_post({"executionId": "wf-1"}) - with patch("requests.post", mock_post): - runtime._start_via_server(agent, "hello", timeout=5) + runtime._agent_client.start_agent = MagicMock(return_value={"executionId": "wf-1"}) + runtime._start_via_server(agent, "hello", timeout=5) - payload = mock_post.call_args[1]["json"] + payload = runtime._agent_client.start_agent.call_args[0][0] assert payload["timeoutSeconds"] == 5 def test_no_timeout_omits_field(self, runtime): """run() with no timeout does not include timeoutSeconds in payload.""" agent = Agent(name="test", model="openai/gpt-4o") - mock_post = _mock_requests_post({"executionId": "wf-1"}) - with patch("requests.post", mock_post): - runtime._start_via_server(agent, "hello") + runtime._agent_client.start_agent = MagicMock(return_value={"executionId": "wf-1"}) + runtime._start_via_server(agent, "hello") - payload = mock_post.call_args[1]["json"] + payload = runtime._agent_client.start_agent.call_args[0][0] assert "timeoutSeconds" not in payload @patch("conductor.ai.agents.runtime.runtime.time.sleep", return_value=None) @@ -2691,8 +2594,8 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080") - return AgentRuntime(config=config) + config = AgentConfig() + return AgentRuntime(settings=config) def test_warns_on_unrecognized_kwargs(self, runtime, caplog): """run(agent, prompt, foo=1) logs a warning.""" @@ -2731,57 +2634,36 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_get_status_404_raises_agent_not_found(self, runtime): - """get_status with a 404 response raises AgentNotFoundError.""" - import requests - + """get_status propagates AgentNotFoundError from the agent client (404).""" from conductor.ai.agents.exceptions import AgentNotFoundError - mock_resp = MagicMock() - mock_resp.status_code = 404 - mock_resp.text = "Not Found" - mock_resp.raise_for_status.side_effect = requests.exceptions.HTTPError(response=mock_resp) - - with patch("requests.get", return_value=mock_resp): - with pytest.raises(AgentNotFoundError) as exc_info: - runtime.get_status("nonexistent-id") - assert exc_info.value.status_code == 404 + runtime._agent_client.get_status.side_effect = AgentNotFoundError(404, "Not Found") + with pytest.raises(AgentNotFoundError) as exc_info: + runtime.get_status("nonexistent-id") + assert exc_info.value.status_code == 404 def test_get_status_500_raises_agent_api_error(self, runtime): - """get_status with a 500 response raises AgentAPIError (not AgentNotFoundError).""" - import requests - + """get_status propagates AgentAPIError (not AgentNotFoundError) on a 500.""" from conductor.ai.agents.exceptions import AgentAPIError, AgentNotFoundError - mock_resp = MagicMock() - mock_resp.status_code = 500 - mock_resp.text = "Internal Server Error" - mock_resp.raise_for_status.side_effect = requests.exceptions.HTTPError(response=mock_resp) - - with patch("requests.get", return_value=mock_resp): - with pytest.raises(AgentAPIError) as exc_info: - runtime.get_status("some-id") - assert exc_info.value.status_code == 500 - assert not isinstance(exc_info.value, AgentNotFoundError) + runtime._agent_client.get_status.side_effect = AgentAPIError(500, "Internal Server Error") + with pytest.raises(AgentAPIError) as exc_info: + runtime.get_status("some-id") + assert exc_info.value.status_code == 500 + assert not isinstance(exc_info.value, AgentNotFoundError) def test_respond_error_wrapped(self, runtime): - """respond() wraps HTTPError in AgentAPIError.""" - import requests - + """respond() propagates AgentAPIError from the agent client.""" from conductor.ai.agents.exceptions import AgentAPIError - mock_resp = MagicMock() - mock_resp.status_code = 400 - mock_resp.text = "Bad Request" - mock_resp.raise_for_status.side_effect = requests.exceptions.HTTPError(response=mock_resp) - - with patch("requests.post", return_value=mock_resp): - with pytest.raises(AgentAPIError) as exc_info: - runtime.respond("wf-id", "some output") - assert exc_info.value.status_code == 400 + runtime._agent_client.respond.side_effect = AgentAPIError(400, "Bad Request") + with pytest.raises(AgentAPIError) as exc_info: + runtime.respond("wf-id", "some output") + assert exc_info.value.status_code == 400 class TestSSEFallbackWarnsOnce: @@ -2794,12 +2676,12 @@ def runtime(self): from conductor.ai.agents.runtime.config import AgentConfig from conductor.ai.agents.runtime.runtime import AgentRuntime - config = AgentConfig(server_url="http://fake:8080", auto_start_workers=False) - return AgentRuntime(config=config) + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) def test_sse_fallback_logs_once(self, runtime, caplog): """SSE fallback message should be logged only on the first failure.""" - from conductor.ai.agents.runtime.http_client import SSEUnavailableError + from conductor.client.agent_client import SSEUnavailableError call_count = 0 @@ -2939,3 +2821,92 @@ def handoff_check(transfer_to, active_agent, is_transfer=True): # Allowed: coder → qa_tester result = handoff_check("qa_tester", "2") assert result == {"active_agent": "3", "handoff": True} + + +class TestRunSettings: + """Per-run run_settings overrides land in the /agent/start payload.""" + + @pytest.fixture() + def runtime(self): + with patch("conductor.client.orkes_clients.OrkesClients"): + with patch("conductor.ai.agents.runtime.worker_manager.TaskHandler", create=True): + from conductor.ai.agents.runtime.config import AgentConfig + from conductor.ai.agents.runtime.runtime import AgentRuntime + + config = AgentConfig(auto_start_workers=False) + return AgentRuntime(settings=config) + + def _agent_config(self, runtime): + """The agentConfig dict sent in the last start_agent payload.""" + return runtime._agent_client.start_agent.call_args[0][0]["agentConfig"] + + def test_overrides_land_in_payload(self, runtime): + runtime._agent_client.start_agent = MagicMock(return_value={"executionId": "wf-1"}) + agent = Agent(name="t", model="openai/gpt-4o", temperature=0.2) + runtime._start_via_server( + agent, + "hi", + run_settings=RunSettings( + model="anthropic/claude-sonnet-4-6", + temperature=0.9, + max_tokens=100, + reasoning_effort="high", + thinking_budget_tokens=2048, + ), + ) + cfg = self._agent_config(runtime) + assert cfg["model"] == "anthropic/claude-sonnet-4-6" + assert cfg["temperature"] == 0.9 + assert cfg["maxTokens"] == 100 + assert cfg["reasoningEffort"] == "high" + assert cfg["thinkingConfig"] == {"enabled": True, "budgetTokens": 2048} + + def test_none_keeps_agent_settings(self, runtime): + runtime._agent_client.start_agent = MagicMock(return_value={"executionId": "wf-2"}) + agent = Agent(name="t", model="openai/gpt-4o", temperature=0.2) + runtime._start_via_server(agent, "hi") + cfg = self._agent_config(runtime) + assert cfg["model"] == "openai/gpt-4o" + assert cfg["temperature"] == 0.2 + + def test_partial_override_only_changes_given_fields(self, runtime): + runtime._agent_client.start_agent = MagicMock(return_value={"executionId": "wf-3"}) + agent = Agent(name="t", model="openai/gpt-4o", temperature=0.2) + runtime._start_via_server(agent, "hi", run_settings=RunSettings(temperature=0.9)) + cfg = self._agent_config(runtime) + assert cfg["temperature"] == 0.9 + assert cfg["model"] == "openai/gpt-4o" + assert "maxTokens" not in cfg + + def test_temperature_zero_applies(self, runtime): + runtime._agent_client.start_agent = MagicMock(return_value={"executionId": "wf-4"}) + agent = Agent(name="t", model="openai/gpt-4o", temperature=0.7) + runtime._start_via_server(agent, "hi", run_settings=RunSettings(temperature=0.0)) + assert self._agent_config(runtime)["temperature"] == 0.0 + + def test_accepts_dict(self, runtime): + runtime._agent_client.start_agent = MagicMock(return_value={"executionId": "wf-5"}) + agent = Agent(name="t", model="openai/gpt-4o") + runtime._start_via_server(agent, "hi", run_settings={"model": "anthropic/x", "max_tokens": 50}) + cfg = self._agent_config(runtime) + assert cfg["model"] == "anthropic/x" + assert cfg["maxTokens"] == 50 + + def test_run_forwards_run_settings(self, runtime): + rs = RunSettings(temperature=0.5) + runtime._prepare_workers = MagicMock() + runtime._start_via_server = MagicMock(return_value=("wf-6", None, [])) + runtime._poll_status_until_complete = MagicMock( + return_value=AgentStatus( + execution_id="wf-6", is_complete=True, output="ok", status="COMPLETED" + ) + ) + runtime.run(Agent(name="t", model="openai/gpt-4o"), "hi", run_settings=rs) + assert runtime._start_via_server.call_args.kwargs["run_settings"] is rs + + def test_start_forwards_run_settings(self, runtime): + rs = RunSettings(model="anthropic/x") + runtime._prepare_workers = MagicMock() + runtime._start_via_server = MagicMock(return_value=("wf-7", None, [])) + runtime.start(Agent(name="t", model="openai/gpt-4o"), "hi", run_settings=rs) + assert runtime._start_via_server.call_args.kwargs["run_settings"] is rs diff --git a/tests/unit/ai/test_runtime_metadata_registration.py b/tests/unit/ai/test_runtime_metadata_registration.py new file mode 100644 index 00000000..e3fc31c6 --- /dev/null +++ b/tests/unit/ai/test_runtime_metadata_registration.py @@ -0,0 +1,45 @@ +# Copyright (c) 2025 Agentspan +# Licensed under the MIT License. See LICENSE file in the project root for details. + +"""The SDK must stamp a credential-requiring worker's declared secret names onto +TaskDef.runtimeMetadata at registration, so the host resolves them at poll time +and the SDK's overwrite-registration does not wipe the value.""" + +from conductor.ai.agents.runtime.runtime import ( + _credential_names, + _default_task_def, + _passthrough_task_def, +) + + +class TestCredentialNames: + def test_extracts_string_names(self): + assert _credential_names(["GH_TOKEN", "AWS_KEY"]) == ["GH_TOKEN", "AWS_KEY"] + + def test_dedups_preserving_order(self): + assert _credential_names(["A", "B", "A"]) == ["A", "B"] + + def test_reads_name_attribute_and_ignores_junk(self): + class Cred: + def __init__(self, name): + self.name = name + + assert _credential_names([Cred("X"), 42, None, "Y"]) == ["X", "Y"] + + def test_none_and_empty(self): + assert _credential_names(None) == [] + assert _credential_names([]) == [] + + +class TestTaskDefStamping: + def test_default_task_def_stamps_runtime_metadata(self): + td = _default_task_def("gh_tool", runtime_metadata=["GH_TOKEN"]) + assert td.runtime_metadata == ["GH_TOKEN"] + + def test_default_task_def_no_metadata_leaves_none(self): + td = _default_task_def("plain_tool") + assert not td.runtime_metadata # None or empty — never a clobbering [] + + def test_passthrough_task_def_stamps_runtime_metadata(self): + td = _passthrough_task_def("fw_worker", runtime_metadata=["MY_SECRET"]) + assert td.runtime_metadata == ["MY_SECRET"] diff --git a/tests/unit/ai/test_signals.py b/tests/unit/ai/test_signals.py index d06e371d..e53ddd89 100644 --- a/tests/unit/ai/test_signals.py +++ b/tests/unit/ai/test_signals.py @@ -62,39 +62,27 @@ async def test_stop_async_calls_runtime(self): class TestRuntimeStop: """AgentRuntime.stop() calls the server stop endpoint and sends WMQ unblock.""" - @patch("conductor.ai.agents.runtime.runtime.req_lib", create=True) - def test_stop_calls_server_endpoint(self, mock_requests): + def test_stop_calls_server_endpoint(self): from conductor.ai.agents.runtime.runtime import AgentRuntime rt = AgentRuntime.__new__(AgentRuntime) rt._workflow_client = MagicMock() - rt._agent_api_url = MagicMock(return_value="http://localhost/api/agent/wf-1/stop") - rt._agent_api_headers = MagicMock(return_value={}) + rt._agent_client = MagicMock() - # Mock requests.post - import requests as req_lib - with patch.object(req_lib, "post") as mock_post: - mock_post.return_value = MagicMock(status_code=200) - mock_post.return_value.raise_for_status = MagicMock() - rt.stop("wf-1") - mock_post.assert_called_once() + rt.stop("wf-1") + rt._agent_client.stop.assert_called_once_with("wf-1") def test_stop_sends_wmq_unblock(self): from conductor.ai.agents.runtime.runtime import AgentRuntime rt = AgentRuntime.__new__(AgentRuntime) rt._workflow_client = MagicMock() - rt._agent_api_url = MagicMock(return_value="http://localhost/api/agent/wf-1/stop") - rt._agent_api_headers = MagicMock(return_value={}) - - import requests as req_lib - with patch.object(req_lib, "post") as mock_post: - mock_post.return_value = MagicMock(status_code=200) - mock_post.return_value.raise_for_status = MagicMock() - rt.stop("wf-1") - rt._workflow_client.send_message.assert_called_once_with( - "wf-1", {"_signal": "stop"} - ) + rt._agent_client = MagicMock() + + rt.stop("wf-1") + rt._workflow_client.send_message.assert_called_once_with( + "wf-1", {"_signal": "stop"} + ) def test_stop_wmq_failure_is_swallowed(self): """If WMQ send fails, stop still succeeds.""" @@ -103,14 +91,9 @@ def test_stop_wmq_failure_is_swallowed(self): rt = AgentRuntime.__new__(AgentRuntime) rt._workflow_client = MagicMock() rt._workflow_client.send_message.side_effect = Exception("no WMQ") - rt._agent_api_url = MagicMock(return_value="http://localhost/api/agent/wf-1/stop") - rt._agent_api_headers = MagicMock(return_value={}) + rt._agent_client = MagicMock() - import requests as req_lib - with patch.object(req_lib, "post") as mock_post: - mock_post.return_value = MagicMock(status_code=200) - mock_post.return_value.raise_for_status = MagicMock() - rt.stop("wf-1") # should not raise + rt.stop("wf-1") # should not raise # ── AgentRuntime.signal() ────────────────────────────────────────────── @@ -123,34 +106,19 @@ def test_signal_calls_server_endpoint(self): from conductor.ai.agents.runtime.runtime import AgentRuntime rt = AgentRuntime.__new__(AgentRuntime) - rt._agent_api_url = MagicMock(return_value="http://localhost/api/agent/wf-1/signal") - rt._agent_api_headers = MagicMock(return_value={}) - - import requests as req_lib - with patch.object(req_lib, "post") as mock_post: - mock_post.return_value = MagicMock(status_code=200) - mock_post.return_value.raise_for_status = MagicMock() - rt.signal("wf-1", "budget cut, wrap up") - mock_post.assert_called_once_with( - "http://localhost/api/agent/wf-1/signal", - json={"message": "budget cut, wrap up"}, - headers={}, - timeout=30, - ) + rt._agent_client = MagicMock() + + rt.signal("wf-1", "budget cut, wrap up") + rt._agent_client.signal.assert_called_once_with("wf-1", "budget cut, wrap up") def test_signal_clear(self): from conductor.ai.agents.runtime.runtime import AgentRuntime rt = AgentRuntime.__new__(AgentRuntime) - rt._agent_api_url = MagicMock(return_value="http://localhost/api/agent/wf-1/signal") - rt._agent_api_headers = MagicMock(return_value={}) - - import requests as req_lib - with patch.object(req_lib, "post") as mock_post: - mock_post.return_value = MagicMock(status_code=200) - mock_post.return_value.raise_for_status = MagicMock() - rt.signal("wf-1", "") - mock_post.assert_called_once() + rt._agent_client = MagicMock() + + rt.signal("wf-1", "") + rt._agent_client.signal.assert_called_once_with("wf-1", "") # ── wait_for_message_tool blocking parameter ──────────────────────────── diff --git a/tests/unit/ai/test_sse_client.py b/tests/unit/ai/test_sse_client.py index 960f114d..75f58dda 100644 --- a/tests/unit/ai/test_sse_client.py +++ b/tests/unit/ai/test_sse_client.py @@ -163,14 +163,25 @@ def _make_runtime(server_url: str, auth_key: str = None, auth_secret: str = None We only need the _stream_sse() method, so we bypass full initialization by creating the config and setting it directly. """ - config = AgentConfig( - server_url=server_url, - auth_key=auth_key, - auth_secret=auth_secret, - streaming_enabled=True, + from conductor.client.configuration.configuration import Configuration + from conductor.client.configuration.settings.authentication_settings import ( + AuthenticationSettings, + ) + from conductor.client.orkes_clients import OrkesClients + + config = AgentConfig(streaming_enabled=True) + auth = ( + AuthenticationSettings(key_id=auth_key, key_secret=auth_secret or "") + if auth_key + else None ) rt = object.__new__(AgentRuntime) rt._config = config + rt._conductor_config = Configuration( + server_api_url=server_url, authentication_settings=auth + ) + rt._clients = OrkesClients(configuration=rt._conductor_config) + rt._agent_client = rt._clients.get_agent_client() return rt @@ -396,11 +407,24 @@ def test_connection_refused_raises_sse_unavailable(self): class TestStreamSSEAuth: + @pytest.fixture(autouse=True) + def _isolate_ambient_auth(self, monkeypatch): + """Isolate from ambient auth env (CI sets CONDUCTOR_AUTH_*). These tests + assert on the *configured* auth only; a service-account key in the + environment would otherwise leak into the Configuration and add an + unexpected X-Authorization header.""" + for k in ( + "CONDUCTOR_AUTH_KEY", + "CONDUCTOR_AUTH_SECRET", + "AGENTSPAN_AUTH_KEY", + "AGENTSPAN_AUTH_SECRET", + "AGENTSPAN_API_KEY", + ): + monkeypatch.delenv(k, raising=False) + def test_auth_key_secret_mints_x_authorization(self): """auth_key/auth_secret are exchanged for a JWT via POST /token (the secured-host contract, e.g. orkes) and sent as X-Authorization.""" - from conductor.ai.agents._internal.token_utils import _TOKEN_CACHE - scenario = { "events": [ {"event": "done", "id": "1", "data": _java_event("done", output="ok")}, @@ -410,7 +434,6 @@ def test_auth_key_secret_mints_x_authorization(self): server = MockSSEServer(scenario) url = server.start() try: - _TOKEN_CACHE.clear() rt = _make_runtime(url, auth_key="my-key", auth_secret="my-secret") events = list(rt._stream_sse("test-wf")) assert len(events) == 1 @@ -425,7 +448,6 @@ def test_auth_key_secret_mints_x_authorization(self): assert "X-Auth-Secret" not in headers finally: server.stop() - _TOKEN_CACHE.clear() def test_no_auth_headers_when_not_configured(self): scenario = { diff --git a/tests/unit/ai/test_sse_parsing.py b/tests/unit/ai/test_sse_parsing.py index 036a1fad..64ef2689 100644 --- a/tests/unit/ai/test_sse_parsing.py +++ b/tests/unit/ai/test_sse_parsing.py @@ -10,6 +10,7 @@ import json from conductor.ai.agents.runtime.runtime import AgentRuntime +from conductor.client.orkes.orkes_agent_client import OrkesAgentClient # ── Helpers ────────────────────────────────────────────────────────── @@ -56,7 +57,7 @@ def test_parse_single_event(self): "data": {"type": "thinking", "executionId": "wf-1", "content": "llm"}, } ) - events = list(AgentRuntime._parse_sse(iter(lines))) + events = list(OrkesAgentClient._parse_sse(iter(lines))) assert len(events) == 1 assert events[0]["event"] == "thinking" assert events[0]["id"] == "1" @@ -68,7 +69,7 @@ def test_parse_multiple_events(self): {"event": "tool_call", "id": "2", "data": {"type": "tool_call", "toolName": "search"}}, {"event": "done", "id": "3", "data": {"type": "done", "output": "answer"}}, ) - events = list(AgentRuntime._parse_sse(iter(lines))) + events = list(OrkesAgentClient._parse_sse(iter(lines))) assert len(events) == 3 assert events[0]["event"] == "thinking" assert events[1]["event"] == "tool_call" @@ -76,7 +77,7 @@ def test_parse_multiple_events(self): def test_parse_heartbeat_comment(self): lines = [":heartbeat", ""] - events = list(AgentRuntime._parse_sse(iter(lines))) + events = list(OrkesAgentClient._parse_sse(iter(lines))) assert len(events) == 1 assert events[0]["_heartbeat"] is True @@ -95,7 +96,7 @@ def test_parse_heartbeat_mixed_with_events(self): 'data:{"output":"ok"}', "", ] - events = list(AgentRuntime._parse_sse(iter(lines))) + events = list(OrkesAgentClient._parse_sse(iter(lines))) assert len(events) == 4 assert events[0]["_heartbeat"] is True assert events[1]["event"] == "thinking" @@ -110,21 +111,21 @@ def test_parse_bytes_input(self): b'data:{"type":"thinking","content":"processing"}', b"", ] - events = list(AgentRuntime._parse_sse(iter(lines))) + events = list(OrkesAgentClient._parse_sse(iter(lines))) assert len(events) == 1 assert events[0]["event"] == "thinking" assert events[0]["data"]["content"] == "processing" def test_parse_event_without_id(self): lines = ["event:thinking", 'data:{"type":"thinking"}', ""] - events = list(AgentRuntime._parse_sse(iter(lines))) + events = list(OrkesAgentClient._parse_sse(iter(lines))) assert len(events) == 1 assert events[0]["id"] is None assert events[0]["event"] == "thinking" def test_parse_event_without_event_type(self): lines = ["id:1", 'data:{"type":"message","content":"hi"}', ""] - events = list(AgentRuntime._parse_sse(iter(lines))) + events = list(OrkesAgentClient._parse_sse(iter(lines))) assert len(events) == 1 assert events[0]["event"] is None assert events[0]["id"] == "1" @@ -132,7 +133,7 @@ def test_parse_event_without_event_type(self): def test_parse_invalid_json_falls_back_to_content(self): lines = ["event:error", "data:not-valid-json", ""] - events = list(AgentRuntime._parse_sse(iter(lines))) + events = list(OrkesAgentClient._parse_sse(iter(lines))) assert len(events) == 1 assert events[0]["data"] == {"content": "not-valid-json"} @@ -144,17 +145,17 @@ def test_parse_multiline_data(self): 'data:"hello world"}', "", ] - events = list(AgentRuntime._parse_sse(iter(lines))) + events = list(OrkesAgentClient._parse_sse(iter(lines))) assert len(events) == 1 # Multiline data lines are joined with \n then parsed assert events[0]["data"]["output"] == "hello world" def test_parse_empty_input(self): - events = list(AgentRuntime._parse_sse(iter([]))) + events = list(OrkesAgentClient._parse_sse(iter([]))) assert len(events) == 0 def test_parse_only_blank_lines(self): - events = list(AgentRuntime._parse_sse(iter(["", "", ""]))) + events = list(OrkesAgentClient._parse_sse(iter(["", "", ""]))) assert len(events) == 0 @@ -331,7 +332,7 @@ def test_all_event_types_round_trip(self): ] lines = _make_sse_lines(*wire_events) - parsed = list(AgentRuntime._parse_sse(iter(lines))) + parsed = list(OrkesAgentClient._parse_sse(iter(lines))) assert len(parsed) == 10 agent_events = [AgentRuntime._sse_to_agent_event(p, "wf-1") for p in parsed] diff --git a/tests/unit/ai/test_token_utils.py b/tests/unit/ai/test_token_utils.py index 1d402477..def1486e 100644 --- a/tests/unit/ai/test_token_utils.py +++ b/tests/unit/ai/test_token_utils.py @@ -1,25 +1,16 @@ # Copyright (c) 2025 Agentspan # Licensed under the MIT License. See LICENSE file in the project root for details. -"""Unit tests for the shared agent-API auth token helpers. +"""Unit tests for the JWT-expiry decoder. -Uses a real in-process HTTP server (no mocks, per repo test policy) to emulate the -host's POST /token mint endpoint. +Token minting/caching moved to the SDK ``ApiClient`` — see ``test_agent_http.py`` +for the worker HTTP path. This module only covers ``decode_jwt_exp``. """ import base64 import json -import threading -from http.server import BaseHTTPRequestHandler, HTTPServer -import pytest - -from conductor.ai.agents._internal.token_utils import ( - _TOKEN_CACHE, - agent_api_auth_headers, - decode_jwt_exp, - resolve_agent_api_token, -) +from conductor.ai.agents._internal.token_utils import decode_jwt_exp def _jwt(exp: int) -> str: @@ -28,82 +19,17 @@ def _jwt(exp: int) -> str: return f"{header}.{payload}.sig" -class _TokenHandler(BaseHTTPRequestHandler): - mint_count = 0 - token = _jwt(4102444800) # far future - - def do_POST(self): # noqa: N802 - if self.path != "/token": - self.send_response(404) - self.end_headers() - return - length = int(self.headers.get("Content-Length", 0)) - body = json.loads(self.rfile.read(length)) - if body.get("keyId") != "kid" or body.get("keySecret") != "ksec": - self.send_response(401) - self.end_headers() - return - type(self).mint_count += 1 - data = json.dumps({"token": self.token}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - - def log_message(self, *args): # silence - pass - - -@pytest.fixture() -def token_server(): - _TokenHandler.mint_count = 0 - srv = HTTPServer(("127.0.0.1", 0), _TokenHandler) - t = threading.Thread(target=srv.serve_forever, daemon=True) - t.start() - url = f"http://127.0.0.1:{srv.server_address[1]}" - yield url - srv.shutdown() - _TOKEN_CACHE.clear() +def test_decode_jwt_exp_reads_exp(): + assert decode_jwt_exp(_jwt(1700000000)) == 1700000000.0 -def test_decode_jwt_exp(): - assert decode_jwt_exp(_jwt(1700000000)) == 1700000000.0 +def test_decode_jwt_exp_opaque_or_malformed_returns_zero(): assert decode_jwt_exp("opaque-token") == 0.0 assert decode_jwt_exp("") == 0.0 + assert decode_jwt_exp("a.b") == 0.0 # payload segment not valid JSON -def test_api_key_passthrough(): - # An explicit api_key is already a token — no mint, returned as-is. - assert resolve_agent_api_token("http://unused", api_key="tok-123") == "tok-123" - assert agent_api_auth_headers("http://unused", api_key="tok-123") == { - "X-Authorization": "tok-123" - } - - -def test_anonymous_returns_none(): - assert resolve_agent_api_token("http://unused") is None - assert agent_api_auth_headers("http://unused") == {} - - -def test_mint_and_cache(token_server): - tok = resolve_agent_api_token(token_server, auth_key="kid", auth_secret="ksec") - assert tok == _TokenHandler.token - # Second call must hit the cache (no second mint). - tok2 = resolve_agent_api_token(token_server, auth_key="kid", auth_secret="ksec") - assert tok2 == tok - assert _TokenHandler.mint_count == 1 - assert agent_api_auth_headers(token_server, auth_key="kid", auth_secret="ksec") == { - "X-Authorization": tok - } - - -def test_expired_cache_reminted(token_server): - _TOKEN_CACHE[(token_server, "kid")] = (_jwt(100), 100.0) # long expired - tok = resolve_agent_api_token(token_server, auth_key="kid", auth_secret="ksec") - assert tok == _TokenHandler.token - assert _TokenHandler.mint_count == 1 # re-minted exactly once - - -def test_bad_credentials_none(token_server): - assert resolve_agent_api_token(token_server, auth_key="kid", auth_secret="WRONG") is None \ No newline at end of file +def test_decode_jwt_exp_missing_exp_returns_zero(): + header = base64.urlsafe_b64encode(b'{"alg":"HS256"}').rstrip(b"=").decode() + payload = base64.urlsafe_b64encode(json.dumps({"sub": "x"}).encode()).rstrip(b"=").decode() + assert decode_jwt_exp(f"{header}.{payload}.sig") == 0.0 diff --git a/tests/unit/ai/test_worker_entries.py b/tests/unit/ai/test_worker_entries.py index 4583f9a2..af2b189f 100644 --- a/tests/unit/ai/test_worker_entries.py +++ b/tests/unit/ai/test_worker_entries.py @@ -329,9 +329,82 @@ def test_transfer_entries_pickle(self): ) assert asyncio.run(pickle.loads(pickle.dumps(TransferNoopEntry()))()) == {} + # Echoes the hand-off message so it is visible in the task output. + assert asyncio.run(pickle.loads(pickle.dumps(TransferNoopEntry()))(message="do X")) == { + "message": "do X" + } msg = asyncio.run(pickle.loads(pickle.dumps(TransferUnreachableEntry("a_transfer_to_b")))()) assert "a_transfer_to_b is not available" in msg + def test_check_transfer_extracts_message(self): + import asyncio + + from conductor.ai.agents.runtime._worker_entries import CheckTransferEntry + + entry = pickle.loads(pickle.dumps(CheckTransferEntry())) + out = asyncio.run( + entry( + tool_calls=[ + { + "name": "ceo_transfer_to_engineering_lead", + "inputParameters": { + "method": "ceo_transfer_to_engineering_lead", + "message": "Design the REST API", + }, + } + ] + ) + ) + assert out == { + "is_transfer": True, + "transfer_to": "engineering_lead", + "transfer_message": "Design the REST API", + } + + def test_check_transfer_no_transfer_and_missing_message(self): + import asyncio + + from conductor.ai.agents.runtime._worker_entries import CheckTransferEntry + + entry = CheckTransferEntry() + assert asyncio.run(entry(tool_calls=None)) == { + "is_transfer": False, + "transfer_to": "", + "transfer_message": "", + } + # Transfer without a message arg (older tool schema) → empty message + out = asyncio.run( + entry(tool_calls=[{"name": "a_transfer_to_b", "inputParameters": {"method": "x"}}]) + ) + assert out == {"is_transfer": True, "transfer_to": "b", "transfer_message": ""} + + def test_check_transfer_multiple_calls_first_wins_and_reports_dropped(self): + import asyncio + + from conductor.ai.agents.runtime._worker_entries import CheckTransferEntry + + entry = CheckTransferEntry() + out = asyncio.run( + entry( + tool_calls=[ + { + "name": "ceo_transfer_to_engineering_lead", + "inputParameters": {"message": "eng task"}, + }, + { + "name": "ceo_transfer_to_marketing_lead", + "inputParameters": {"message": "mkt task"}, + }, + ] + ) + ) + assert out["is_transfer"] is True + assert out["transfer_to"] == "engineering_lead" + assert out["transfer_message"] == "eng task" + assert out["dropped_transfers"] == [ + {"transfer_to": "marketing_lead", "message": "mkt task"} + ] + # ── Framework worker entries (Group C) ─────────────────────────────────── diff --git a/tests/unit/orkes/test_orkes_agent_client.py b/tests/unit/orkes/test_orkes_agent_client.py new file mode 100644 index 00000000..c2294703 --- /dev/null +++ b/tests/unit/orkes/test_orkes_agent_client.py @@ -0,0 +1,200 @@ +# Copyright (c) 2025 Agentspan +# Licensed under the MIT License. See LICENSE file in the project root for details. + +"""Tests for OrkesAgentClient — the /agent/* control-plane client. + +Uses a real in-process HTTP server (no mocks, per repo test policy) that emulates +the agent endpoints plus the POST /token mint. Verifies request path/method/body, +that the X-Authorization JWT is minted via the shared ApiClient and sent, and that +HTTP errors map to the agent SDK exceptions. +""" + +import base64 +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +from conductor.client.agent_client import AgentClient +from conductor.client.ai.agent_errors import AgentAPIError, AgentNotFoundError +from conductor.client.configuration.configuration import Configuration +from conductor.client.orkes.orkes_agent_client import OrkesAgentClient + + +def _jwt(exp: int) -> str: + header = base64.urlsafe_b64encode(b'{"alg":"HS256"}').rstrip(b"=").decode() + payload = base64.urlsafe_b64encode(json.dumps({"exp": exp}).encode()).rstrip(b"=").decode() + return f"{header}.{payload}.sig" + + +_JWT = _jwt(4102444800) # far future + + +class _AgentHandler(BaseHTTPRequestHandler): + # Recorded for assertions. + last_path = None + last_method = None + last_body = None + last_x_authorization = None + mint_count = 0 + # Test knobs. + status_for_agent = 200 + + def _read_body(self): + length = int(self.headers.get("Content-Length", 0)) + return json.loads(self.rfile.read(length)) if length else None + + def _json(self, code, obj): + data = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_POST(self): # noqa: N802 + body = self._read_body() + if self.path == "/api/token": + type(self).mint_count += 1 + self._json(200, {"token": _JWT}) + return + self._record() + type(self).last_body = body + self._json(type(self).status_for_agent, {"executionId": "wf-1", "ok": True}) + + def do_GET(self): # noqa: N802 + self._record() + self._json(type(self).status_for_agent, {"status": "COMPLETED", "results": []}) + + def _record(self): + type(self).last_path = self.path + type(self).last_method = self.command + type(self).last_x_authorization = self.headers.get("X-Authorization") + + def log_message(self, *args): # silence + pass + + +@pytest.fixture() +def server(): + _AgentHandler.last_path = None + _AgentHandler.last_method = None + _AgentHandler.last_body = None + _AgentHandler.last_x_authorization = None + _AgentHandler.mint_count = 0 + _AgentHandler.status_for_agent = 200 + srv = HTTPServer(("127.0.0.1", 0), _AgentHandler) + t = threading.Thread(target=srv.serve_forever, daemon=True) + t.start() + yield f"http://127.0.0.1:{srv.server_address[1]}/api" + srv.shutdown() + + +def _client(server_url, *, auth=False): + from conductor.client.configuration.settings.authentication_settings import ( + AuthenticationSettings, + ) + + cfg = Configuration(server_api_url=server_url) + if auth: + cfg.authentication_settings = AuthenticationSettings(key_id="kid", key_secret="ksec") + else: + cfg.authentication_settings = None + return OrkesAgentClient(cfg) + + +def test_is_agent_client_interface(server): + assert isinstance(_client(server), AgentClient) + + +def test_start_agent_path_and_body(server): + c = _client(server) + result = c.start_agent({"prompt": "hello"}) + assert result["executionId"] == "wf-1" + assert _AgentHandler.last_path == "/api/agent/start" + assert _AgentHandler.last_method == "POST" + assert _AgentHandler.last_body == {"prompt": "hello"} + + +def test_deploy_and_compile_paths(server): + c = _client(server) + c.deploy_agent({"agentConfig": {}}) + assert _AgentHandler.last_path == "/api/agent/deploy" + c.compile_agent({"agentConfig": {}}) + assert _AgentHandler.last_path == "/api/agent/compile" + + +def test_status_execution_and_hitl_paths(server): + c = _client(server) + c.get_status("wf-9") + assert _AgentHandler.last_path == "/api/agent/wf-9/status" + assert _AgentHandler.last_method == "GET" + c.get_execution("wf-9") + assert _AgentHandler.last_path == "/api/agent/execution/wf-9" + c.respond("wf-9", {"approved": True}) + assert _AgentHandler.last_path == "/api/agent/wf-9/respond" + c.stop("wf-9") + assert _AgentHandler.last_path == "/api/agent/wf-9/stop" + c.signal("wf-9", "note") + assert _AgentHandler.last_path == "/api/agent/wf-9/signal" + assert _AgentHandler.last_body == {"message": "note"} + + +def test_list_executions_sends_query(server): + c = _client(server) + c.list_executions({"agentName": "a", "size": 5}) + assert _AgentHandler.last_path.startswith("/api/agent/executions?") + assert "agentName=a" in _AgentHandler.last_path + + +def test_anonymous_sends_no_auth_header(server): + c = _client(server) + c.start_agent({"prompt": "x"}) + assert _AgentHandler.last_x_authorization is None + assert _AgentHandler.mint_count == 0 + + +def test_auth_mints_jwt_and_sends_x_authorization(server): + c = _client(server, auth=True) + c.start_agent({"prompt": "x"}) + # ApiClient mints once (eagerly) and sends the JWT as X-Authorization. + assert _AgentHandler.mint_count >= 1 + assert _AgentHandler.last_x_authorization == _JWT + + +def test_404_maps_to_agent_not_found(server): + _AgentHandler.status_for_agent = 404 + c = _client(server) + with pytest.raises(AgentNotFoundError) as exc: + c.get_status("missing") + assert exc.value.status_code == 404 + + +def test_500_maps_to_agent_api_error(server): + _AgentHandler.status_for_agent = 500 + c = _client(server) + with pytest.raises(AgentAPIError) as exc: + c.start_agent({"prompt": "x"}) + assert exc.value.status_code == 500 + assert not isinstance(exc.value, AgentNotFoundError) + + +def test_parse_sse_events_and_heartbeats(): + lines = [ + ": heartbeat", + "event: thinking", + 'data: {"content": "processing"}', + "", + "event: done", + "id: 42", + 'data: {"output": "result"}', + "", + ] + events = list(OrkesAgentClient._parse_sse(iter(lines))) + assert events[0] == {"_heartbeat": True} + assert events[1]["event"] == "thinking" + assert events[1]["data"]["content"] == "processing" + assert events[2]["event"] == "done" + assert events[2]["id"] == "42" + assert events[2]["data"]["output"] == "result" diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..bda02073 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.13"