Skip to content

Commit 90d8d23

Browse files
jwulfCopilot
andauthored
feat: code-first agent orchestration in Effect (S3 demo) (#2)
* feat: code-first agent orchestration in Effect (S3 demo) Author the research-agent orchestration code-first with @nanobpm/workflow defineFlow (classify -> parallel retrieval -> convergence loop with human review + non-interrupting SLA boundary -> publish/archive), within the block-structured expressible subset, with LLM prompt-bound agent tasks. Implement each agent as an idiomatic Effect program over an Effect job-worker surface: typed error channel (transient vs permanent), Schedule exponential backoff, Layer-injected Llm service, and Scope'd job leases. Add a thin Effect client/worker surface over @nanobpm/workflow's Promise SDK as a stand-in for the published @camunda8/orchestration-cluster-api/effect (#437/#438), swappable when it ships. Prove the orchestration's time-bounded behaviour deterministically with a TestClock-based suite (retry backoff, per-call deadlines) plus model-derivation assertions. Targets Effect v4 (beta), pinned. Closes #1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Josh Wulf <josh@magikcraft.io> * fix: address Copilot review — error mapping, retry typing, revision feedback - Llm.ts: map HTTP 429/5xx to TransientAgentError and other non-OK (4xx) to PermanentAgentError; pass already-typed agent errors through the catch instead of blanket-wrapping every failure as transient. - worker.ts: loosen retrySchedule to Pick<AgentSpec, 'baseBackoff'|'maxRetries'> and drop the now-unnecessary 'spec as AgentSpec<never>' cast in handleJob. - errors.ts: correct the TransientAgentError doc — backoff exhaustion fails the job with retries: 0 (raising an incident), not 'with retries left'. - research-agent.ts: capture reviewer revisionNotes on the review task output and feed it into the next synthesize round so revisions actually round-trip; regenerated bpmn/research-agent.bpmn accordingly. - worker.test.ts: drop no-op .then(async (r) => r). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Josh Wulf <josh@magikcraft.io> * docs: adopt review decision C for reviewer-nudge SLA Per PR #2 review (human decision C): keep the non-interrupting nudge boundary and await an upstream @nanobpm/workflow fire-and-forget (non-converging / end-event) boundary body rather than dropping the nudge or moving a spurious token into the verdict gateway. - research-agent.ts: document the boundary convergence limitation (the nudge path merges into `review`, so a fired SLA can double-instantiate it) and the intended fire-and-forget reviewer nudge on `review` timed by `reviewNudgeSla`, tracked in #3. - main.ts: explain `reviewNudgeSla` is reserved forward-looking config for that future reviewer-nudge boundary, not dead config. Tracking issue #3 files the upstream API feature request. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Josh Wulf <josh@magikcraft.io> * fix: harden deterministic LLM, worker defects, and error detail Address Copilot review (PR #2, round 4): - Llm.ts: LlmDeterministic no longer echoes the prompt into its response. The echoed head leaked prompt text into logs/variables and could inject downstream parsing delimiters (classify splits on '|'), producing unstable structured output. Now returns only the stable digest. - Llm.ts: clip + normalize whitespace on non-OK LLM HTTP response bodies before embedding them in the error reason (bounded, readable incidents). - worker.ts: wrap the nano-sdk jobHandler in try/catch so an unexpected runtime/layer defect fails the job deterministically (retries: 0) instead of escaping as an unhandled rejection and risking silent redelivery. - worker.test.ts: drop an unused recorder and its vacuous assertion. Comment-only BPMN unchanged; typecheck clean; all 14 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Josh Wulf <josh@magikcraft.io> * fix: guard job-outcome observer so it can't fail the job Wrap onOutcome in a swallowing notify() in both the success and defect paths of the nano-sdk jobHandler: an observer throw (logging/metrics) must not be mistaken for a worker defect (which would fail an already-completed job) nor escape as an unhandled rejection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Josh Wulf <josh@magikcraft.io> * fix: unify worker-defect message and pin transient-retry assertions Address Copilot suppressed advisories: - worker.ts: emit the same `worker defect (...)` string as both the engine incident errorMessage and the JobOutcome.reason so onOutcome logs/metrics correlate with the engine's recorded message. - test/worker.test.ts: the transient-retry test computed tries/result but never asserted them; pin tries===3 and result._tag==="completed", and drop the unused `actions` destructure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Josh Wulf <josh@magikcraft.io> * fix: make worker shutdown finalizers best-effort; drop unused recordedRound Swallow rejections from ManagedRuntime.dispose() and worker stop/stopGracefully in their scope finalizers so a shutdown-time failure surfaces as best-effort cleanup instead of a scope-finalizer defect (graceful Ctrl-C / test teardown). recordRevision no longer emits the unused recordedRound variable — the round bump already lives in the model's io.output (=round + 1 -> round). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Josh Wulf <josh@magikcraft.io> * fix: map search sourceCount into process variables Honor the Findings envelope contract: search-web/search-kb declare and return sourceCount, but the model's io.output only mapped =findings, so Zeebe ioMapping dropped sourceCount. Map =sourceCount into webSourceCount /kbSourceCount and regenerate bpmn/research-agent.bpmn. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Josh Wulf <josh@magikcraft.io> --------- Signed-off-by: Josh Wulf <josh@magikcraft.io> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 680ac56 commit 90d8d23

28 files changed

Lines changed: 2109 additions & 13 deletions

.github/workflows/ci.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: ci
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
build:
10+
runs-on: ubuntu-latest
11+
strategy:
12+
matrix:
13+
node: [22.x, 24.x]
14+
steps:
15+
- uses: actions/checkout@v4
16+
- uses: actions/setup-node@v4
17+
with:
18+
node-version: ${{ matrix.node }}
19+
cache: npm
20+
- run: npm ci
21+
- name: Typecheck
22+
run: npm run typecheck
23+
- name: Test (TestClock-deterministic)
24+
run: npm test
25+
- name: Emit derived BPMN
26+
run: npm run model:emit

README.md

Lines changed: 80 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,91 @@ Agent workflows authored code-first with [`@nanobpm/workflow`](https://www.npmjs
99

1010
## The stack
1111

12-
| Layer | Piece |
13-
| ----- | ----- |
14-
| **Model** | `@nanobpm/workflow` `defineFlow` — code-first, derives Zeebe/C8 BPMN |
15-
| **Transport / client** | `@camunda8/orchestration-cluster-api/effect` — Effect surface over the C8 SDK ([S1](https://github.com/camunda/orchestration-cluster-api-js/issues/437)) |
16-
| **Agents** | Effect job workers — `activateJobs → handle → complete/fail` as Effect ([S2](https://github.com/camunda/orchestration-cluster-api-js/issues/438)) |
12+
| Layer | Piece | In this repo |
13+
| ----- | ----- | ------------ |
14+
| **Model** | `@nanobpm/workflow` `defineFlow` — code-first, derives Zeebe/C8 BPMN | [`src/model/research-agent.ts`](src/model/research-agent.ts) |
15+
| **Transport / client** | Effect surface over the C8 SDK ([S1 #437](https://github.com/camunda/orchestration-cluster-api-js/issues/437)) | [`src/effect/client.ts`](src/effect/client.ts) |
16+
| **Agent runtime** | Effect job workers — `activate → handle → complete/fail` ([S2 #438](https://github.com/camunda/orchestration-cluster-api-js/issues/438)) | [`src/effect/worker.ts`](src/effect/worker.ts) |
17+
| **Agents** | one `Effect` program per capability | [`src/agents/`](src/agents/) |
1718

18-
Effect is an **optional** layer at every level — the demo opts in; it is never forced on the
19-
Promise-based Camunda 8 SDK.
19+
Effect is an **optional** layer at every SDK level — the demo opts in; it is never forced on the
20+
Promise-based Camunda 8 SDK. It targets **Effect v4** (beta), pinned deliberately.
2021

21-
## Why Effect
22+
> **Transport note.** The published transport target is
23+
> `@camunda8/orchestration-cluster-api/effect` (S1 #437 + S2 #438). Until that subpath ships, the
24+
> demo drives the engine through `@nanobpm/workflow`'s Promise-based `WorkflowClient` behind the
25+
> thin Effect surface in [`src/effect/`](src/effect/). When `./effect` publishes, swap the internals
26+
> of `client.ts` / `worker.ts` — the model and the agents are unchanged.
2227
23-
`Schedule` retries, `Layer` dependency injection, structured concurrency, and typed error
24-
boundaries for agent workers — plus `TestClock`, which makes the orchestration's time-bounded
25-
behaviour (activation intervals, timeouts, eventual consistency) deterministic under test.
28+
## The model — an agent convergence loop
2629

27-
## Status
30+
Authored code-first and kept inside `defineFlow`'s block-structured, single-entry/single-exit
31+
subset:
2832

29-
🚧 Scaffolding. Tracked by the epic **[nanobpm/nano-ide#412](https://github.com/nanobpm/nano-ide/issues/412)** (S3).
33+
```
34+
classify — an LLM agent classifies the question (prompt-bound task)
35+
parallel(search-web, search-kb) — two retrieval agents run concurrently (AND fork/join)
36+
loop: — a durable agent convergence loop
37+
synthesize — an LLM agent drafts an answer from the findings
38+
boundary(SLA, non-interrupting) — a timer SLA pings the reviewer, leaving synthesis running
39+
human(review) — a reviewer approves / requests a revision
40+
switch(verdict):
41+
approve -> publish; break — publish and leave the loop
42+
default -> record-revision — bump the round and loop back to re-synthesize
43+
archive — bookkeeping after the loop
44+
```
45+
46+
The LLM agent tasks bind a prompt resource (`zeebe:linkedResource … linkName="prompt"`, see
47+
[`prompts/`](prompts/)) — the shape nano-workforce agent pools consume. Emit the derived, deployable
48+
BPMN (with diagram interchange) to inspect it in a modeller / Operate:
49+
50+
```sh
51+
npm run model:emit # writes bpmn/research-agent.bpmn
52+
```
53+
54+
## The agents — Effect all the way down
55+
56+
Each agent is `(job) => Effect<variables, AgentError, R>`:
57+
58+
- **Typed errors**`TransientAgentError` (retryable) vs `PermanentAgentError` (incident), a real
59+
failure channel instead of thrown surprises ([`src/effect/errors.ts`](src/effect/errors.ts)).
60+
- **`Schedule` retries** — the worker retries transient failures on an exponential backoff and
61+
short-circuits permanent ones ([`src/effect/worker.ts`](src/effect/worker.ts)).
62+
- **`Layer` DI** — agents depend on an `Llm` service; the demo injects a deterministic stand-in and
63+
can swap in a real OpenAI-compatible `LlmLive` ([`src/effect/Llm.ts`](src/effect/Llm.ts)).
64+
- **`Scope`d job lease** — each worker is acquired/released with `Effect.acquireRelease`, so leases
65+
are always returned when the runtime shuts down.
66+
67+
## Why Effect — the `TestClock` hook
68+
69+
`Schedule` retries, `Layer` DI, structured concurrency, and typed error boundaries for agent
70+
workers — plus **`TestClock`**, which makes the orchestration's time-bounded behaviour (retry
71+
backoff, per-call deadlines, activation intervals) deterministic under test. Every backoff and
72+
deadline is an Effect sleep against a virtual clock, so the tests advance time explicitly and assert
73+
exact outcomes — no real waiting, no flakiness. See [`test/worker.test.ts`](test/worker.test.ts) and
74+
[`test/agents.test.ts`](test/agents.test.ts).
75+
76+
## Run it
77+
78+
Prereqs: **Node ≥ 22.6** (uses `--experimental-strip-types` to run TypeScript directly).
79+
80+
```sh
81+
npm install
82+
npm run typecheck # tsc --noEmit
83+
npm test # node --test, all deterministic (no engine, no network)
84+
```
85+
86+
Deploy + run against a Camunda 8 / nanobpmn engine:
87+
88+
```sh
89+
export CAMUNDA_REST_ADDRESS=http://localhost:8080 # your gateway
90+
export CAMUNDA_TOKEN=... # if required
91+
export LLM_API_KEY=... # optional; omit to use the deterministic LLM
92+
npm run deploy
93+
```
94+
95+
`npm run deploy` deploys the model, leases the eight agent workers, starts one instance, and serves
96+
until interrupted.
3097

3198
## License
3299

0 commit comments

Comments
 (0)