Skip to content

Commit 612821b

Browse files
authored
Merge pull request #416 from conductor-oss/feature/combine-agentspan
Merge Agentspan agent SDK into conductor-python
2 parents e567e4f + 0b67394 commit 612821b

527 files changed

Lines changed: 122836 additions & 137 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/agent-e2e.yml

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
name: Agent E2E
2+
3+
# Runs the agent e2e suites (e2e/) against the released Agentspan
4+
# server JAR — a full Conductor server with the agent runtime baked in.
5+
# tests/integration/ai stays manual-only (same as upstream Agentspan,
6+
# which never ran its tests/integration in CI): run it locally against
7+
# a live server with `pytest tests/integration/ai`.
8+
#
9+
# These tests call real LLMs via the OPENAI_API_KEY / ANTHROPIC_API_KEY
10+
# repo secrets. Fork PRs cannot see repo secrets, so for them the run
11+
# fails at the silently-empty guard rather than passing vacuously.
12+
13+
on: [pull_request, workflow_dispatch]
14+
15+
concurrency:
16+
group: agent-e2e-${{ github.ref }}
17+
cancel-in-progress: true
18+
19+
env:
20+
AGENTSPAN_VERSION: "0.4.0" # pinned server/CLI release — bump deliberately
21+
22+
jobs:
23+
agent-e2e:
24+
runs-on: ubuntu-latest
25+
timeout-minutes: 45
26+
env:
27+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
28+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
29+
AGENTSPAN_SERVER_URL: http://localhost:8080/api
30+
AGENTSPAN_CLI_PATH: ${{ github.workspace }}/agentspan
31+
steps:
32+
- name: Checkout code
33+
uses: actions/checkout@v4
34+
35+
- name: Set up Python
36+
uses: actions/setup-python@v5
37+
with:
38+
python-version: '3.12'
39+
cache: 'pip'
40+
41+
- name: Set up Java
42+
uses: actions/setup-java@v4
43+
with:
44+
distribution: temurin
45+
java-version: '21'
46+
47+
- name: Cache server JAR
48+
id: jar_cache
49+
uses: actions/cache@v4
50+
with:
51+
path: agentspan-server.jar
52+
key: agentspan-server-${{ env.AGENTSPAN_VERSION }}
53+
54+
- name: Download server JAR from release
55+
if: steps.jar_cache.outputs.cache-hit != 'true'
56+
env:
57+
GH_TOKEN: ${{ github.token }}
58+
run: |
59+
gh release download "v${AGENTSPAN_VERSION}" --repo agentspan-ai/agentspan \
60+
--pattern "agentspan-server-${AGENTSPAN_VERSION}.jar" --output agentspan-server.jar
61+
62+
- name: Download CLI binary from release
63+
env:
64+
GH_TOKEN: ${{ github.token }}
65+
run: |
66+
gh release download "v${AGENTSPAN_VERSION}" --repo agentspan-ai/agentspan \
67+
--pattern "agentspan_linux_amd64" --output agentspan
68+
chmod +x agentspan
69+
70+
- name: Install SDK + test deps
71+
run: |
72+
python -m pip install --upgrade pip
73+
pip install -e '.[agents]'
74+
pip install pytest pytest-asyncio pytest-xdist pytest-rerunfailures mcp-testkit
75+
76+
- name: Start mcp-testkit
77+
run: |
78+
mcp-testkit --transport http --port 3001 &
79+
sleep 2
80+
81+
- name: Start server
82+
run: |
83+
java -jar agentspan-server.jar --server.port=8080 > server.log 2>&1 &
84+
85+
- name: Wait for server health
86+
run: |
87+
for i in $(seq 1 45); do
88+
if curl -sf http://localhost:8080/health | grep -q '"healthy"[[:space:]]*:[[:space:]]*true'; then
89+
echo "server healthy after ~$((i*2))s"; exit 0
90+
fi
91+
sleep 2
92+
done
93+
echo "::error::server failed to become healthy within 90s"
94+
tail -100 server.log
95+
exit 1
96+
97+
- name: Run e2e suites
98+
run: |
99+
mkdir -p results
100+
pytest e2e/ -v --tb=short -n 3 --dist=loadgroup \
101+
--junitxml=results/junit-e2e.xml
102+
103+
# e2e/conftest.py pytest.skip()s the whole session when the server is
104+
# unreachable — without this guard a boot failure after the health gate
105+
# (or a future gate regression) would yield a green job that ran nothing.
106+
- name: Guard against silently-empty runs
107+
if: always()
108+
run: |
109+
python - <<'EOF'
110+
import glob
111+
import sys
112+
import xml.etree.ElementTree as ET
113+
114+
total = executed = 0
115+
for path in glob.glob("results/junit-*.xml"):
116+
root = ET.parse(path).getroot()
117+
for suite in root.iter("testsuite"):
118+
t = int(suite.get("tests", 0))
119+
sk = int(suite.get("skipped", 0))
120+
total += t
121+
executed += t - sk
122+
print(f"executed {executed}/{total} tests")
123+
sys.exit(0 if executed > 0 else 1)
124+
EOF
125+
126+
- name: Generate HTML report
127+
if: always()
128+
run: |
129+
python e2e/report_generator.py results/junit-e2e.xml results/report.html || true
130+
131+
- name: Upload results
132+
if: always()
133+
uses: actions/upload-artifact@v4
134+
with:
135+
name: agent-e2e-results
136+
path: |
137+
results/
138+
server.log
139+
retention-days: 14

.github/workflows/pull_request.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ jobs:
2727
pip install -e .
2828
pip install pytest pytest-cov coverage
2929
30+
- name: Verify agents import without extras installed
31+
id: agent_base_import
32+
continue-on-error: true
33+
run: |
34+
python -c "import conductor.ai.agents"
35+
36+
- name: Install agents extra
37+
run: |
38+
pip install -e '.[agents]'
39+
pip install pytest-asyncio
40+
3041
- name: Prepare coverage directory
3142
run: |
3243
mkdir -p ${{ env.COVERAGE_DIR }}
@@ -91,5 +102,5 @@ jobs:
91102
file: coverage.xml
92103

93104
- name: Check test results
94-
if: steps.unit_tests.outcome == 'failure' || steps.bc_tests.outcome == 'failure' || steps.serdeser_tests.outcome == 'failure'
105+
if: steps.unit_tests.outcome == 'failure' || steps.bc_tests.outcome == 'failure' || steps.serdeser_tests.outcome == 'failure' || steps.agent_base_import.outcome == 'failure'
95106
run: exit 1

CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Canonical metrics mode: opt-in harmonized metric surface via `WORKER_CANONICAL_METRICS=true` -- [details](METRICS.md#detailed-technical-notes--unreleased)
1313
- `MetricsSettings` gains `clean_directory` and `clean_dead_pids` for opt-in stale `.db` file cleanup (both default to `False`)
14-
- `CONDUCTOR_MP_START_METHOD` env var to control the worker pool's multiprocessing start method
14+
- `SchedulerClient` now carries the schedule lifecycle operations itself: `pause(reason=)`, `resume`, `delete`, `run_now`, `preview_next`, `reconcile` (declarative tri-state sync) — with typed errors (`ScheduleNotFound`, `InvalidCronExpression`, ...). `pause_schedule` gains an optional `reason=` (stored by OSS Conductor servers; ignored by Orkes servers)
1515

1616
### Changed
1717

18-
- **Multiprocessing start method now defaults to `spawn` on all platforms** (was `fork` everywhere except Windows). `fork` caused silently-restarting `SIGSEGV` worker subprocesses on macOS (exitcode `-11`) and fork-with-held-lock deadlocks on POSIX. Opt back in with `CONDUCTOR_MP_START_METHOD=fork`. Entrypoint scripts must use the standard `if __name__ == "__main__":` guard, and workers must be defined at module level
18+
- **Multiprocessing start method now defaults to `spawn` on all platforms** (was `fork` everywhere except Windows). `fork` caused silently-restarting `SIGSEGV` worker subprocesses on macOS (exitcode `-11`) and fork-with-held-lock deadlocks on POSIX.
1919
- Legacy metrics emit unchanged by default; no env var required
2020
- `metrics_collector.py` is now a compatibility shim; `from conductor.client.telemetry.metrics_collector import MetricsCollector` continues to work
21+
- `get_schedule` returns a typed `WorkflowSchedule` (or `None` for missing schedules) instead of a raw camelCase dict, matching its declared annotation and [docs/SCHEDULE.md](docs/SCHEDULE.md); dict-consumers should switch to attribute access or `to_dict()`
2122

2223
### Fixed
2324

2425
- `@worker_task` workers are now picklable, making the decorator path work with the `spawn`/`forkserver` start methods (fixes `TypeError: cannot pickle '_thread.lock' object` and `PicklingError: it's not the same object as ...`; issues #264, #271): `Worker.api_client` is created lazily in the worker process, runtime state (locks, pending async tasks, background loop) is excluded from pickling and rebuilt in the child, and decorated functions are pickled as importable references resolved in the child
2526
- `TaskHandler.start_processes()` no longer hangs the interpreter when a worker fails to start (e.g., unpicklable state under `spawn`); it now cleans up already-started subprocesses and raises with actionable guidance
2627
- Worker processes killed by a signal now log a diagnostic hint (signal number, `PYTHONFAULTHANDLER=1` guidance) instead of restarting silently
28+
- Per-schedule pause/resume now work on both Conductor server families: the client sends `PUT` (the OSS Conductor dialect — the spec-generated `GET` failed there) and transparently falls back to `GET` on a 405 for Orkes servers

METRICS.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -378,10 +378,6 @@ unreleased metrics harmonization work. For a summary, see the project
378378
`create_metrics_collector` (the per-worker path) is non-destructive and
379379
only ensures the directory exists, so spawned/restarted workers never wipe
380380
live sibling metrics.
381-
- `CONDUCTOR_MP_START_METHOD` env var (`spawn` / `fork` / `forkserver`;
382-
default `fork` on POSIX, `spawn` on Windows) to control the worker pool's
383-
multiprocessing start method (motivated by a `prometheus_client` lock-fork
384-
deadlock).
385381
- Harness manifest sets `WORKER_CANONICAL_METRICS=true`; `harness/main.py`
386382
logs which collector is active.
387383

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ If you find [Conductor](https://github.com/conductor-oss/conductor) useful, plea
2323
* [Monitoring with Metrics](#monitoring-with-metrics)
2424
* [Managing Workflow Executions](#managing-workflow-executions)
2525
* [AI & LLM Workflows](#ai--llm-workflows)
26+
* [AI Agents](#ai-agents)
2627
* [Why Conductor?](#why-conductor)
2728
* [Examples](#examples)
2829
* [Documentation](#documentation)
@@ -67,6 +68,13 @@ pip install conductor-python
6768

6869
> **Already in a virtual environment?** Skip the `venv` step and run `pip install conductor-python` directly. On macOS, Windows, or in containers where system Python is not locked down, you can also install globally.
6970
71+
Building durable AI agents instead? Install the `agents` extra (or a per-framework extra —
72+
see [AI Agents](#ai-agents)):
73+
74+
```shell
75+
pip install 'conductor-python[agents]'
76+
```
77+
7078
## 60-Second Quickstart
7179

7280
**Step 1: Create a workflow**
@@ -407,6 +415,35 @@ python examples/rag_workflow.py document.pdf "What are the key findings?"
407415
408416
---
409417
418+
## AI Agents
419+
420+
Beyond the workflow-embedded LLM calls above, `conductor-python` also ships a durable
421+
agent-authoring layer — `Agent`, `AgentRuntime`, `tool`, guardrails, handoffs, and multi-agent
422+
strategies — where the agent itself compiles into a Conductor workflow that runs on the server,
423+
with retries, durable state, streaming, and human-in-the-loop pauses built in.
424+
425+
```shell
426+
pip install 'conductor-python[agents]'
427+
```
428+
429+
```python
430+
from conductor.ai.agents import Agent, AgentRuntime
431+
432+
agent = Agent(name="greeter", model="anthropic/claude-sonnet-4-6",
433+
instructions="You are a friendly assistant.")
434+
435+
with AgentRuntime() as runtime:
436+
result = runtime.run(agent, "Say hello.")
437+
print(result.output)
438+
```
439+
440+
Framework integrations (LangChain, LangGraph, Google ADK, the OpenAI Agents SDK, Claude Agent SDK)
441+
are optional extras — see [docs/agents/getting-started.md](docs/agents/getting-started.md) for
442+
per-framework install instructions, and [examples/agents/](examples/agents/) for 270+ runnable
443+
examples. Full docs: [docs/agents/](docs/agents/).
444+
445+
---
446+
410447
## Why Conductor?
411448
412449
| | |
@@ -467,6 +504,7 @@ End-to-end examples covering all APIs for each domain:
467504
| [Secrets](docs/SECRET_MANAGEMENT.md) | Secret storage |
468505
| [Prompts](docs/PROMPT.md) | AI/LLM prompt templates |
469506
| [Integrations](docs/INTEGRATION.md) | AI/LLM provider integrations |
507+
| [AI Agents](docs/agents/README.md) | Durable agent authoring: `Agent`, `AgentRuntime`, tools, guardrails, handoffs |
470508
| [Metrics](METRICS.md) | Prometheus metrics collection |
471509
| [Examples](examples/README.md) | Complete examples catalog |
472510

docs/SCHEDULE.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,31 @@ Operations for controlling schedule execution state.
3535
| `resume_schedule()` | `PUT /api/scheduler/schedules/{name}/resume` | Resume a specific schedule | [Example](#resume-schedule) |
3636
| `resume_all_schedules()` | `PUT /api/scheduler/schedules/resume` | Resume all schedules | [Example](#resume-all-schedules) |
3737

38+
> **Verb compatibility:** OSS Conductor maps per-schedule pause/resume as `PUT`;
39+
> Orkes Conductor servers map them as `GET`. The client sends `PUT` first and
40+
> transparently falls back to `GET` on a 405 — both server families work without
41+
> configuration. The optional `reason` parameter on `pause_schedule` is stored by
42+
> OSS servers only.
43+
44+
## Schedule Lifecycle Helpers
45+
46+
Beyond the endpoint methods above, `SchedulerClient` carries higher-level lifecycle
47+
operations (shared with the agents layer). They raise typed errors
48+
(`ScheduleNotFound`, `InvalidCronExpression`, `ScheduleNameConflict` from
49+
`conductor.client.ai.schedule_errors`) instead of raw `ApiException`.
50+
51+
| Method | Signature | Description |
52+
|--------|-----------|-------------|
53+
| `pause()` | `(wire_name, reason=None)` | Pause with typed errors |
54+
| `resume()` | `(wire_name)` | Resume with typed errors |
55+
| `delete()` | `(wire_name)` | Delete with typed errors |
56+
| `preview_next()` | `(cron, n=5, start_at=None, end_at=None) -> list[int]` | Next `n` epoch-ms fire times |
57+
| `run_now()` | `(info: ScheduleInfo) -> str` | Fire the schedule's workflow once, returns execution id |
58+
| `reconcile()` | `(workflow_name, desired: list[Schedule] \| None)` | Declarative sync: `None` no-op, `[]` purge, `[...]` upsert+prune |
59+
60+
Reads, writes, and lists have no duplicated helper forms — `get_schedule()`,
61+
`save_schedule()`, and `get_all_schedules()` are the source of truth.
62+
3863
## Schedule Execution APIs
3964

4065
APIs for managing and querying schedule executions.
@@ -169,6 +194,9 @@ Pause a specific schedule to stop executions.
169194
```python
170195
scheduler_client.pause_schedule("daily_order_processing")
171196
print("Schedule paused")
197+
198+
# Optionally record why (stored by OSS Conductor servers; ignored by Orkes servers):
199+
scheduler_client.pause_schedule("daily_order_processing", reason="maintenance window")
172200
```
173201

174202
#### Pause All Schedules

docs/WORKER.md

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -279,13 +279,6 @@ Requirements under `spawn` (standard Python multiprocessing rules):
279279
- `configuration`, `metrics_settings`, and `event_listeners` passed to
280280
`TaskHandler` must be picklable
281281

282-
To opt back into the previous behavior (e.g., a Linux deployment relying on
283-
fork's copy-on-write memory sharing):
284-
285-
```shell
286-
export CONDUCTOR_MP_START_METHOD=fork # spawn (default) | fork | forkserver
287-
```
288-
289282
If a worker process dies from a signal repeatedly at startup, set
290283
`PYTHONFAULTHANDLER=1` to capture the crashing stack trace from the child.
291284

docs/agents/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Agentspan Python SDK
2+
3+
> Ships as part of [`conductor-python`](https://pypi.org/project/conductor-python/) — install with the `agents` extra
4+
> (`pip install 'conductor-python[agents]'`) — you're in the right place.
5+
6+
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.
7+
8+
```python
9+
from conductor.ai.agents import Agent, AgentRuntime
10+
11+
agent = Agent(name="greeter", model="anthropic/claude-sonnet-4-6",
12+
instructions="You are a friendly assistant.")
13+
14+
with AgentRuntime() as runtime:
15+
result = runtime.run(agent, "Say hello.")
16+
print(result.output)
17+
```
18+
19+
## Docs
20+
21+
- [Getting started](getting-started.md) — install, env vars, and a running agent in under 30 seconds.
22+
- [Writing agents](writing-agents.md) — the `Agent` class and `@agent`, tools, multi-agent strategies, handoffs, guardrails, termination, callbacks, streaming + HITL, schedules, stateful and instance agents.
23+
- [Framework agents](framework-agents.md) — run agents authored in the OpenAI Agents SDK, LangChain, LangGraph, or the Claude Agent SDK.
24+
- [Advanced](advanced.md) — runtime config, the control-plane `AgentClient`, deploy vs serve vs run vs plan, structured output, credentials, plans (`PLAN_EXECUTE`), skills.
25+
- [API reference](api-reference.md) — the public API surface in one place.
26+
27+
## Import surface
28+
29+
Everything public is importable from `conductor.ai.agents`:
30+
31+
```python
32+
from conductor.ai.agents import Agent, AgentRuntime, tool, agent
33+
```
34+
35+
A small OpenAI-Agents-compatible shim is also exposed at the top level:
36+
37+
```python
38+
from conductor.ai import Runner, function_tool # drop-in for `agents.Runner`
39+
```

0 commit comments

Comments
 (0)