Skip to content

Commit 790e6c8

Browse files
ZhiXiao-Linclaude
andauthored
feat: programmable dynamic workflows (facade, execute_loop, shared budget) + parallel-write safety fix (#64)
* fix(agent): parallel write batch must pass the safety gate The parallel write fast path executed tools directly via the ToolExecutor, bypassing ToolSafetyGate entirely — so with multiple write calls in one turn, permission checks and skill restrictions were not enforced. Make can_run_parallel_write_batch consult the gate itself (single source of truth): only fast-path when, for every call, no active skill restriction forbids the tool AND the permission checker explicitly Allows it. A missing checker resolves to Ask (a Deny without a confirmation manager), so it correctly refuses. This preserves the optimization for the explicit-allow case while closing the bypass. Exposes ToolSafetyGate::{check_skill_restrictions,permission_decision} as pub(crate). * feat(orchestration): programmable Workflow facade, execute_loop, shared budget Compose the existing combinators into a runtime-driven, Claude-Code-style dynamic workflow mechanism, reusing the AgentExecutor / SessionStore / WorkflowCheckpoint / BudgetGuard seams rather than inventing parallel ones. - Workflow facade (orchestration/workflow.rs): a cheaply-clonable handle whose verbs agent/parallel/phase/pipeline each delegate to one combinator; phase is a named, resumable barrier ({root_id}/{index}:{name}) emitting WorkflowEvent milestones. Control flow lives in the host language. - execute_loop + LoopDecision (combinators.rs): bounded loop-until-dry with a mandatory max_iterations hard cap. - WorkflowBudget (orchestration/workflow_budget.rs): an aggregating BudgetGuard that sums token spend from every step into one shared ledger (soft cap). - Wire BudgetGuard through ChildRunContext so a single guard spans a fan-out (closes a gap: budget was per-session_id only). - AgentSession::workflow()/workflow_with_token_budget(): pre-wire executor, store, per-step events, session-derived root id, and the shared budget. Also fix agent_executor() to install parent_context (security/skill/workspace), so orchestrated steps are neither more nor less privileged than delegated ones. - README: document the Workflow facade, loop, and shared budget. Tests: full lib suite green; new unit + e2e coverage for every verb, resume, loop caps, budget aggregation, and a real child-agent workflow step. * feat(sdk): budgeted workflow fan-out (workflowParallel / workflow_parallel) Expose the shared workflow budget through both SDKs via a session method built on AgentSession::workflow_with_token_budget(): run a fan-out where every child agent feeds ONE token ledger and, once the cap is reached, further child LLM calls are denied (a soft cap; the in-flight fan-out is never force-killed). Returns the per-step outcomes plus the ledger snapshot. - Node (napi): session.workflowParallel(specs, budgetTokens?) -> { outcomes, budget } - Python (pyo3): session.workflow_parallel(specs, budget_tokens=None) -> dict - Regenerate node generated.d.ts; document both in the README SDK examples. Both native modules build (napi build / maturin) and pass an offline smoke test (empty fan-out takes no LLM path; correct ledger snapshot). Full orchestration behavior is covered by the core crate's test suite. * test: integration coverage for the Workflow facade, loop & shared budget - core/tests/test_workflow_facade_real_llm.rs (#[ignore], real-LLM gated like test_orchestration_real_llm.rs): the Workflow facade phase + milestones, execute_loop's max_iterations hard cap, session.workflow() running a real child agent with the shared ledger accumulating spend, and sequential budget enforcement (a step started after the cap is denied). - sdk/node/test.mjs: offline workflowParallel shape check (empty fan-out, ledger snapshot) in the existing smoke run. - sdk/python/tests/test_workflow_parallel.py: the same offline check for Python. Verified: core suite green; the integration target compiles and registers its 4 ignored cases; node 'npm test' and the python smoke both pass offline. * refactor(sdk): fold the budgeted fan-out into parallel(specs, budgetTokens?) Drop the awkwardly-named workflowParallel / workflow_parallel methods. The only difference from parallel was a shared token budget, so make it an optional argument on parallel itself instead of leaking the internal 'workflow' facade name into the public API. - Node: parallel(specs, budgetTokens?) -> Array<StepOutcomeObject> | WorkflowParallelResult (napi Either). No budget => the plain outcomes array (unchanged, non-breaking); a budget => { outcomes, budget } (ledger snapshot). - Python: parallel(specs, budget_tokens=None) -> list | dict, same split. - Update README SDK examples; node test.mjs and the renamed test_parallel_budget.py cover both the array and budgeted shapes offline. Rebuilt both native modules (napi build / maturin); npm test and the python smoke pass; clippy/fmt clean. --------- Co-authored-by: Claude <claude@anthropic.com>
1 parent bc14c31 commit 790e6c8

19 files changed

Lines changed: 1855 additions & 21 deletions

README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ Everything else is an extension of that loop.
4848
`session.parallel` / `pipeline` / `parallelResumable` (Node) and
4949
`parallel` / `pipeline` / `parallel_resumable` (Python). See
5050
[Programmable Orchestration](#programmable-orchestration) below.
51+
- **Workflow facade, loop & shared budget**`AgentSession::workflow()` returns
52+
a cheaply-clonable `Workflow` that pre-wires the session's executor (inheriting
53+
the same governance as model-driven delegation), persistence store, per-step
54+
event stream, and a session-derived stable root id. Its verbs `agent` /
55+
`parallel` / `phase` / `pipeline` each delegate to one combinator; `phase` is a
56+
named resume boundary that emits `WorkflowEvent` milestones. `execute_loop` /
57+
`LoopDecision` add a bounded loop-until-dry (with a mandatory `max_iterations`
58+
guard). `WorkflowBudget` aggregates token spend from every step into one shared
59+
ledger — a soft, workflow-wide cost cap installed through the existing
60+
`BudgetGuard` seam.
5161

5262
### What's new in 3.3
5363

@@ -608,6 +618,13 @@ results = session.pipeline(
608618
# Resumable: progress is journaled under a workflow id via the session store,
609619
# so an interrupted run (or one resumed on another node) skips completed steps.
610620
outcomes = session.parallel_resumable(specs, "nightly-audit")
621+
622+
# Budgeted fan-out: pass `budget_tokens` to parallel() so every child agent feeds
623+
# ONE shared token ledger; once the cap is hit further child LLM calls are denied
624+
# (a soft cap). With a budget, parallel() returns {outcomes, budget} (the ledger
625+
# snapshot) instead of the plain outcomes list.
626+
res = session.parallel(specs, budget_tokens=500_000)
627+
print(res["budget"]["consumed_tokens"], res["budget"]["limit_tokens"])
611628
```
612629

613630
```javascript
@@ -627,11 +644,61 @@ const results = await session.pipeline(
627644
);
628645

629646
await session.parallelResumable(specs, "nightly-audit");
647+
648+
// Budgeted fan-out: pass a token budget to parallel() so all child agents share
649+
// one ledger (a soft cap). With a budget, parallel() resolves to {outcomes,
650+
// budget} instead of the plain outcomes array.
651+
const { outcomes: out, budget } = await session.parallel(specs, 500_000);
652+
console.log(budget.consumedTokens, budget.limitTokens);
630653
// NOTE: pipeline stage callbacks MUST NOT throw — return null to stop a chain.
631654
// A throw aborts the process (same constraint as setBudgetGuard). A stage that
632655
// hangs past timeoutMs (default 30s) fails closed (treated as null).
633656
```
634657

658+
### The `Workflow` facade (Rust)
659+
660+
`AgentSession::workflow()` returns a cheaply-clonable `Workflow` that bundles the
661+
session's executor, persistence store, per-step event stream, and a stable,
662+
session-derived root id. Control flow is ordinary Rust — `await` a verb, inspect
663+
the outcomes, decide what runs next:
664+
665+
```rust
666+
let wf = session.workflow(); // or session.workflow_with_token_budget(Some(500_000))
667+
668+
// One step, then a *variable* fan-out computed from its result (the "dynamic"
669+
// part: the shape is decided at runtime, not declared up front).
670+
let plan = wf.agent(AgentStepSpec::new("plan", "plan", "plan", goal)).await;
671+
let specs: Vec<AgentStepSpec> = plan.output.lines().enumerate()
672+
.map(|(i, line)| AgentStepSpec::new(format!("impl-{i}"), "general", "impl", line))
673+
.collect();
674+
675+
// `phase` is a NAMED barrier and a resume boundary: with a store it journals
676+
// progress under "{root_id}/{index}:{name}" and emits PhaseStart/PhaseEnd on the
677+
// WorkflowEvent stream (subscribe via `wf.subscribe()`).
678+
let done = wf.phase("implement", specs).await;
679+
let reviews = wf.phase("review", to_review(&done)).await; // budget shared across all phases
680+
```
681+
682+
- **Verbs**`agent` (one step), `parallel` (barrier fan-out), `phase` (named,
683+
resumable barrier + milestones), `pipeline` (per-item staged chains), and the
684+
non-failing `log`. Each delegates to exactly one combinator; the facade owns no
685+
scheduling.
686+
- **Loop**`execute_loop(executor, initial, max_iterations, tx, |round| …)`
687+
returns `LoopDecision::{Continue(specs), Stop}` and runs rounds until the
688+
predicate stops, a round has no work (dry), or `max_iterations` (a **mandatory
689+
hard cap**) is hit — the guard that makes an LLM-driven loop safe.
690+
- **Budget**`workflow_with_token_budget(Some(limit))` installs a
691+
`WorkflowBudget` as every child run's `budget_guard`, so per-turn LLM
692+
accounting feeds **one** shared ledger; `wf.budget_snapshot()` reads it and a
693+
`WorkflowEvent::BudgetExhausted` fires once the cap is reached. It is a *soft*
694+
ceiling: a wide fan-out can race a few in-flight turns past the cap before the
695+
post-call ledger catches up (the framework never force-kills a running
696+
fan-out).
697+
- **Safety** — every step runs through the same `AgentExecutor` seam as
698+
model-driven delegation, so child runs inherit the session's `SecurityProvider`,
699+
skill restrictions, confirmation, and workspace — orchestrated steps are
700+
neither more nor less privileged than delegated ones.
701+
635702
---
636703

637704
## Design Principles

core/src/agent/parallel_tool_runtime.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,38 @@ impl AgentLoop {
8383
}
8484

8585
pub(super) fn can_run_parallel_write_batch(&self, tool_calls: &[ToolCall]) -> bool {
86+
// The parallel fast path executes tools directly via the ToolExecutor
87+
// (see `execute_parallel_write_batch`), bypassing `ToolSafetyGate`. It is
88+
// only safe when the gate would unconditionally EXECUTE every call.
89+
//
90+
// Hooks and HITL confirmation make the decision stateful/interactive
91+
// (pre-tool hooks have side effects; an `Ask` must round-trip to a
92+
// human), so never fast-path when either is configured.
8693
if self.config.hook_engine.is_some()
8794
|| self.config.confirmation_manager.is_some()
8895
|| tool_calls.len() <= 1
8996
{
9097
return false;
9198
}
9299

100+
// Skill restrictions and permission checks live in `ToolSafetyGate`,
101+
// which the parallel path skips entirely. Consult the gate itself (the
102+
// single source of truth) and only fast-path when, for EVERY call, no
103+
// active skill restriction forbids the tool AND the permission checker
104+
// explicitly Allows it. A missing checker resolves to `Ask`, which with
105+
// no confirmation manager is a Deny — so it correctly refuses here.
106+
let gate = crate::safety_gate::ToolSafetyGate::new(&self.config);
107+
let all_allowed = tool_calls.iter().all(|tc| {
108+
gate.check_skill_restrictions(&tc.name).is_none()
109+
&& matches!(
110+
gate.permission_decision(&tc.name, &tc.args),
111+
crate::permissions::PermissionDecision::Allow
112+
)
113+
});
114+
if !all_allowed {
115+
return false;
116+
}
117+
93118
if !tool_calls
94119
.iter()
95120
.all(|tc| is_parallel_safe_write(&tc.name, &tc.args))

core/src/agent/tests.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2003,3 +2003,101 @@ async fn test_agent_end_event_contains_final_text() {
20032003
assert_eq!(usage.total_tokens, 15);
20042004
}
20052005
}
2006+
2007+
/// Regression: the parallel write fast path bypasses `ToolSafetyGate`, so it
2008+
/// may run only when the gate would unconditionally EXECUTE every call. Before
2009+
/// the fix it ignored the permission checker and skill restrictions entirely,
2010+
/// letting denied / ask / skill-restricted writes land ungated.
2011+
#[test]
2012+
fn parallel_write_batch_only_fast_paths_when_gate_would_execute() {
2013+
use crate::llm::ToolCall;
2014+
use crate::permissions::{PermissionChecker, PermissionDecision};
2015+
use crate::skills::{Skill, SkillKind, SkillRegistry};
2016+
2017+
struct Static(PermissionDecision);
2018+
impl PermissionChecker for Static {
2019+
fn check(&self, _tool: &str, _args: &serde_json::Value) -> PermissionDecision {
2020+
self.0
2021+
}
2022+
}
2023+
2024+
fn write_call(id: &str, path: &str) -> ToolCall {
2025+
ToolCall {
2026+
id: id.to_string(),
2027+
name: "write_file".to_string(),
2028+
args: serde_json::json!({ "path": path, "content": "x" }),
2029+
}
2030+
}
2031+
2032+
fn loop_with(
2033+
checker: Option<Arc<dyn PermissionChecker>>,
2034+
skills: Option<Arc<SkillRegistry>>,
2035+
) -> AgentLoop {
2036+
// `skill_registry` overrides the default builtins where needed.
2037+
let config = AgentConfig {
2038+
permission_checker: checker,
2039+
skill_registry: skills,
2040+
..Default::default()
2041+
};
2042+
AgentLoop::new(
2043+
Arc::new(MockLlmClient::new(vec![])),
2044+
Arc::new(ToolExecutor::new("/tmp".to_string())),
2045+
test_tool_context(),
2046+
config,
2047+
)
2048+
}
2049+
2050+
let calls = vec![write_call("a", "a.txt"), write_call("b", "b.txt")];
2051+
let allow = || Some(Arc::new(Static(PermissionDecision::Allow)) as Arc<dyn PermissionChecker>);
2052+
2053+
// Explicit Allow + no restricting skills → fast path is taken.
2054+
assert!(
2055+
loop_with(allow(), None).can_run_parallel_write_batch(&calls),
2056+
"explicit Allow with no restrictions → parallel write batch is allowed"
2057+
);
2058+
2059+
// No permission checker → gate resolves to Ask (a Deny without a confirmation
2060+
// manager), so the fast path must be refused.
2061+
assert!(
2062+
!loop_with(None, None).can_run_parallel_write_batch(&calls),
2063+
"missing checker resolves to Ask/Deny → fast path refused"
2064+
);
2065+
2066+
// Explicit Deny → refused.
2067+
assert!(
2068+
!loop_with(Some(Arc::new(Static(PermissionDecision::Deny))), None)
2069+
.can_run_parallel_write_batch(&calls),
2070+
"permission Deny → fast path refused"
2071+
);
2072+
2073+
// Ask → refused (sequential path would need a human round-trip).
2074+
assert!(
2075+
!loop_with(Some(Arc::new(Static(PermissionDecision::Ask))), None)
2076+
.can_run_parallel_write_batch(&calls),
2077+
"permission Ask → fast path refused"
2078+
);
2079+
2080+
// Allow, but an active skill restriction forbids write_file → refused.
2081+
let restricted = SkillRegistry::new();
2082+
restricted.register_unchecked(Arc::new(Skill {
2083+
name: "read-only".to_string(),
2084+
description: String::new(),
2085+
allowed_tools: Some("read(*)".to_string()),
2086+
disable_model_invocation: false,
2087+
kind: SkillKind::Instruction,
2088+
content: String::new(),
2089+
tags: Vec::new(),
2090+
version: None,
2091+
}));
2092+
assert!(
2093+
!loop_with(allow(), Some(Arc::new(restricted))).can_run_parallel_write_batch(&calls),
2094+
"active skill restriction disallowing write_file → fast path refused"
2095+
);
2096+
2097+
// Default builtins do not restrict → still fast-paths with Allow.
2098+
assert!(
2099+
loop_with(allow(), Some(Arc::new(SkillRegistry::with_builtins())))
2100+
.can_run_parallel_write_batch(&calls),
2101+
"non-restricting builtins → fast path still allowed"
2102+
);
2103+
}

core/src/agent_api.rs

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -932,15 +932,101 @@ impl AgentSession {
932932
/// run against; a host can instead supply its own executor to place steps
933933
/// across a cluster.
934934
pub fn agent_executor(&self) -> Arc<dyn crate::orchestration::AgentExecutor> {
935-
let executor = crate::tools::TaskExecutor::with_mcp(
935+
Arc::new(self.build_task_executor(self.parent_run_context()))
936+
}
937+
938+
/// Build the in-box [`TaskExecutor`](crate::tools::TaskExecutor) for this
939+
/// session, applying `parent` as the child-run capability context. Shared by
940+
/// [`agent_executor`](Self::agent_executor) and [`workflow`](Self::workflow)
941+
/// so both wire children identically.
942+
fn build_task_executor(
943+
&self,
944+
parent: crate::child_run::ChildRunContext,
945+
) -> crate::tools::TaskExecutor {
946+
crate::tools::TaskExecutor::with_mcp(
936947
Arc::clone(&self.agent_registry),
937948
Arc::clone(&self.llm_client),
938949
self.workspace.display().to_string(),
939950
Arc::clone(&self.mcp_manager),
940951
)
952+
.with_parent_context(parent)
941953
.with_subagent_tracker(Arc::clone(&self.subagent_tasks))
942-
.with_max_parallel_tasks(self.config.max_parallel_tasks);
943-
Arc::new(executor)
954+
.with_max_parallel_tasks(self.config.max_parallel_tasks)
955+
}
956+
957+
/// A programmable [`Workflow`](crate::orchestration::Workflow) bound to this
958+
/// session.
959+
///
960+
/// Pre-wired with this session's executor (inheriting the same governance as
961+
/// model-driven delegation), persistence store (so each
962+
/// [`phase`](crate::orchestration::Workflow::phase) is a resume boundary),
963+
/// per-step event stream, and a session-derived stable root id. Control flow
964+
/// is ordinary Rust: `await` a verb, inspect the outcomes, decide what runs
965+
/// next.
966+
pub fn workflow(&self) -> crate::orchestration::Workflow {
967+
self.workflow_with_token_budget(None)
968+
}
969+
970+
/// Like [`workflow`](Self::workflow) but with a hard token ceiling shared
971+
/// across every step. The cap is a best-effort *soft* cost ceiling — under a
972+
/// wide fan-out a few in-flight turns can race past it before the shared
973+
/// ledger catches up (see [`WorkflowBudget`](crate::orchestration::WorkflowBudget)).
974+
pub fn workflow_with_token_budget(
975+
&self,
976+
limit_tokens: Option<u64>,
977+
) -> crate::orchestration::Workflow {
978+
use crate::budget::BudgetGuard;
979+
980+
// One shared ledger for the whole workflow, wrapping the session's own
981+
// budget guard (if any) so a host's per-tenant accounting keeps working.
982+
let mut budget = crate::orchestration::WorkflowBudget::new(limit_tokens);
983+
if let Some(inner) = self.config.budget_guard.clone() {
984+
budget = budget.with_inner(inner);
985+
}
986+
let budget = Arc::new(budget);
987+
988+
// Install the shared ledger as the child runs' budget guard so every
989+
// step's per-turn LLM accounting feeds it.
990+
let mut parent = self.parent_run_context();
991+
parent.budget_guard = Some(Arc::clone(&budget) as Arc<dyn BudgetGuard>);
992+
let executor: Arc<dyn crate::orchestration::AgentExecutor> =
993+
Arc::new(self.build_task_executor(parent));
994+
995+
let mut builder = crate::orchestration::Workflow::builder(executor)
996+
.with_root_id(format!("wf-{}", self.session_id))
997+
.with_budget(Arc::clone(&budget));
998+
if let Some(store) = self.session_store.clone() {
999+
builder = builder.with_store(store);
1000+
}
1001+
if let Some(step_events) = self.tool_context.agent_event_tx.clone() {
1002+
builder = builder.with_step_events(step_events);
1003+
}
1004+
builder.build()
1005+
}
1006+
1007+
/// Build the [`ChildRunContext`](crate::child_run::ChildRunContext) that
1008+
/// orchestrated / delegated child runs inherit from this session.
1009+
///
1010+
/// Mirrors the context the model-driven `task` / `parallel_task` path
1011+
/// installs (see `register_task_capability` in `agent_api/capabilities.rs`)
1012+
/// so a step run through [`agent_executor`](Self::agent_executor) carries the
1013+
/// SAME governance — security provider, skill restrictions, confirmation,
1014+
/// the shared workspace, and the safety limits — instead of weaker, ambient
1015+
/// authority. Sourced from the session's resolved config; `hook_engine`
1016+
/// stays `None` to match the model-driven path.
1017+
pub(crate) fn parent_run_context(&self) -> crate::child_run::ChildRunContext {
1018+
crate::child_run::ChildRunContext {
1019+
security_provider: self.config.security_provider.clone(),
1020+
hook_engine: None,
1021+
skill_registry: self.config.skill_registry.clone(),
1022+
tool_timeout_ms: self.config.tool_timeout_ms,
1023+
max_parallel_tasks: Some(self.config.max_parallel_tasks),
1024+
max_execution_time_ms: self.config.max_execution_time_ms,
1025+
circuit_breaker_threshold: Some(self.config.circuit_breaker_threshold),
1026+
confirmation_manager: self.config.confirmation_manager.clone(),
1027+
workspace_services: Some(Arc::clone(&self.tool_context.workspace_services)),
1028+
budget_guard: self.config.budget_guard.clone(),
1029+
}
9441030
}
9451031

9461032
/// The session's persistence store, if one is configured — needed by the

core/src/agent_api/capabilities.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ fn register_task_capability(
188188
circuit_breaker_threshold: opts.circuit_breaker_threshold,
189189
confirmation_manager: opts.confirmation_manager.clone(),
190190
workspace_services: opts.workspace_services.clone(),
191+
budget_guard: opts.budget_guard.clone(),
191192
};
192193

193194
let registry = Arc::new(registry);

0 commit comments

Comments
 (0)