From 2fb0c8a9b0685cbb5627383ce1f1fa87b93b8a36 Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Tue, 14 Jul 2026 19:57:14 +0530 Subject: [PATCH 1/6] =?UTF-8?q?fix(flows):=20builder=20prompt=20=E2=80=94?= =?UTF-8?q?=20authoritative=20sandbox=20table=20+=20minimal-graph=20guidan?= =?UTF-8?q?ce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B32: replace "Interpreting dry-run results honestly" with an expanded version that adds an authoritative per-node-type sandbox mock behavior table (trigger/agent/tool_call/http_request/code echo shapes vs. transform/condition/switch/split_out/merge real execution), verified against SchemaAwareMockAgentRunner (tinyflows/caps.rs), the vendored tinyflows mock (vendor/tinyflows/src/caps/mock.rs), and the envelope/ non-envelope node executors in vendor/tinyflows/src/nodes/. Adds an explicit "NEVER run isolated probe graphs" directive — in the observed incident the agent burned 3 of its 10-step budget re-discovering documented-but-undocumented mock behavior. B33: add a "Graph complexity — prefer the minimal viable graph" subsection (fold formatting into the agent node, skip split/merge for single-item flows, chain reasoning steps in one agent node, target 3-6 nodes) — the agent previously built an 11-node graph for a task that needs ~4. Zero code risk: prompt-only change. --- .../flows/agents/workflow_builder/prompt.md | 84 ++++++++++++++++--- 1 file changed, 74 insertions(+), 10 deletions(-) diff --git a/src/openhuman/flows/agents/workflow_builder/prompt.md b/src/openhuman/flows/agents/workflow_builder/prompt.md index 3e2367d9a1..4b9e3711d2 100644 --- a/src/openhuman/flows/agents/workflow_builder/prompt.md +++ b/src/openhuman/flows/agents/workflow_builder/prompt.md @@ -452,6 +452,38 @@ Any acting node may carry: Prefer `retry` + `on_error: "route"` for flaky network/tool steps, and `requires_approval` for anything the user would not want to happen unattended. +### Graph complexity — prefer the minimal viable graph + +Build the **smallest graph that fulfills the request**. Every node you add +is a binding to get right, a dry-run cycle to verify, and a point of +failure at runtime. Rules of thumb: + +- **An `agent` node can format its own output.** If the only purpose of a + downstream `code` or `transform` node is to reshape/format/template the + agent's structured output before passing it to a `tool_call`, fold that + formatting into the agent's `prompt` instruction and `output_parser.schema` + instead. The agent is a full LLM — it can produce markdown, HTML, or any + text shape you need. A separate formatting node is only warranted when the + formatting is purely mechanical (date math, string concatenation with no + judgment) and the agent's token cost would be wasted on it. + +- **Avoid split/merge for single-item flows.** `split_out` + downstream + processing + `merge` is for fan-out over a LIST (e.g. "for each issue, + do X"). If the flow processes one item end-to-end (a single calendar + brief, a single email reply), there is no list to fan out — skip the + split/merge entirely. + +- **One agent node can do multiple reasoning steps.** Don't chain two + `agent` nodes when one could handle both tasks in its prompt (e.g. + "extract the key fields AND compose a brief" in one node, rather than + "extract" → "compose" as two nodes). Chain agents only when they need + genuinely different models, schemas, or `agent_ref` profiles. + +- **Target: 3–6 nodes for a simple automation.** A schedule-trigger → + source-tool → agent-summarize → destination-tool flow is 4 nodes. + Most "when X happens, do Y" requests fit in 3–6. If your draft exceeds + 8 nodes, re-examine whether any node can be folded into its neighbor. + ## Style Be concise. Your posture is **clarify genuinely-ambiguous inputs, verify before @@ -508,7 +540,8 @@ nothing. ### Interpreting dry-run results honestly `dry_run_workflow` runs against **mock** capabilities — no real LLM call, -no real tool execution. Two classes of null or placeholder values appear: +no real tool execution, no real HTTP. Two classes of null or placeholder +values appear: 1. **Mock-LLM-output placeholders** — an `agent` node with a correct `output_parser.schema` produces synthetic placeholder values (empty @@ -528,17 +561,48 @@ no real tool execution. Two classes of null or placeholder values appear: entry and the dry run returns `ok: false`. **These are real bugs — never dismiss them.** Fix every one before proposing. +#### Sandbox mock behavior per node type (authoritative — do NOT probe) + +| Node kind | Sandbox output | Enveloped? | What resolves downstream | +|-----------|----------------|------------|---------------------------| +| `trigger` | Passthrough — echoes the `input` value (default `{}`) | No | Whatever was passed as `input` | +| `agent` (with `output_parser.schema`) | Typed placeholder per schema property (`string`→`""`, `number`/`integer`→`0`, `boolean`→`false`, `object`→`{}`, `array`→`[]`, `enum`→its first listed value) | Yes | `=nodes..item.json.` → the placeholder (non-null) | +| `agent` (no schema) | `{ "agent": "", "request": {...}, "connection": ... }` | Yes | Only `.json.agent` / `.json.request` / `.json.connection` resolve; any other `.json.` → null | +| `tool_call` | Required Composio args are preflight-checked first (missing/null → dry run fails before the mock even runs), then echoes `{ "tool": "", "args": {...}, "connection": ... }` — NOT a real API response | Yes | `.json.tool` / `.json.args` / `.json.connection` resolve; a response-shaped field (e.g. `.json.data.` for a real Composio call — see "the envelope" above) does **not**, because the mock echo carries no `data` wrapper. That is a mock-shape gap, not a wiring bug — don't "fix" a correctly-wired `.json.data.` binding just because the dry run can't resolve it | +| `http_request` | `{ "status": 200, "request": {...}, "connection": ... }` | Yes | `.json.status` → `200`; response-body fields → null | +| `code` | `{ "result": }` — the real `source` is NOT executed | **No** | `.item.result` resolves directly (no `.json.` — `code` does not envelope) | +| `transform` | **REAL** execution — evaluates `config.set` expressions against scope | No | Real resolved values | +| `condition` | **REAL** execution — evaluates truthiness on the actual (mock) input data | No | Routes to the real "true"/"false" port | +| `switch` | **REAL** execution — evaluates the routing expression/field | No | Routes to the real matching port | +| `split_out` | **REAL** execution — fans out the array at `config.path` | No | Real fan-out of the mock data | +| `merge` | **REAL** execution — concatenates input items | No | Passthrough | + +**NEVER run isolated probe graphs** (e.g. a throwaway `[trigger, agent, +tool_call]` subgraph) to test whether a node type's output resolves in the +sandbox. The table above is authoritative. If an `agent` node has a correct +`output_parser.schema`, its `.json.` bindings WILL resolve to typed +placeholders — you do not need to verify this experimentally. Run +`dry_run_workflow` on the REAL graph you're building to check your actual +bindings; a probe graph burns tool calls re-discovering what's already +documented above. + **Never say "known sandbox limitation" or "at runtime this works perfectly" to dismiss a dry-run finding.** If the dry run returns `ok: false`, the -graph has a real problem. If it returns `ok: true` with -`routing_divergence_warnings`, say what was unverified and why (the mock -trigger payload routed differently than a real one would), so the user -knows which branches are untested — do not assert they "work perfectly." - -The only thing the sandbox genuinely cannot test is the CONTENT of an LLM's -reply or a real tool's response shape. Everything else — expression paths, -schema declarations, edge wiring, port labels, required args — is fully -testable in the sandbox, and a failure there is a real failure. +graph has a real problem (with the sole documented exception of the +`tool_call` `.json.data.` mock-shape gap above). If it returns +`ok: true` with `routing_divergence_warnings`, say what was unverified and +why (the mock trigger payload routed differently than a real one would), so +the user knows which branches are untested — do not assert they "work +perfectly." + +The only things the sandbox genuinely cannot test are: +- The CONTENT of an LLM's reply (placeholders only) +- The SHAPE of a real tool/HTTP response (echoes only) +- Real code execution output (echoes input under `result`, does not run + `source`) +Everything else — expression paths, schema declarations, edge wiring, port +labels, required args, condition/switch routing — is fully testable in the +sandbox, and a failure there is a real failure. ### Say what you inferred From 8e5be721d85f78bf93ba05d2e4350e6c94a7c71e Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Tue, 14 Jul 2026 20:43:35 +0530 Subject: [PATCH 2/6] fix(flows): builder session path now applies the definition's iteration cap (B31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flows_build built the workflow_builder agent via Agent::from_config_for_agent, which stamps config.agent.clone() onto the session unconditionally — reading the GLOBAL config.agent.max_tool_iterations (default 10) rather than the workflow_builder AgentDefinition's effective_max_iterations() (50, from agent.toml's iteration_policy = "extended"). Only the sub-agent-runner path (spawn_subagent, used when workflow_builder runs as a chat delegate) applied the definition's cap; the direct flows_build RPC path silently got 10, burning its whole budget on a handful of dry-run cycles for anything past a trivial graph. Add apply_builder_iteration_cap, which clones the config and overrides agent.max_tool_iterations from the resolved AgentDefinition before building the agent, with a debug log of the override and a safe fallback to the global default if the registry/definition isn't available. Also raise FLOW_BUILD_TIMEOUT_SECS 300 -> 600 to match FLOW_RUN_TIMEOUT_SECS: at 50 iterations x ~10s, a worst-case run can now approach 500s, which the old 300s bound would have clipped before the (now-correct) iteration cap ever got a chance to. Unit test asserts the builder's resolved cap is 50 (effective_max_iterations()), not the global default of 10, both directly and through the actually-built Agent's agent_config(). --- src/openhuman/flows/ops.rs | 71 +++++++++++++++++++++++++++++++- src/openhuman/flows/ops_tests.rs | 45 ++++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 4975d75d14..d4a309812e 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -3258,7 +3258,13 @@ pub async fn flows_discover( /// Overall safety bound on one `flows_build` run. The `workflow_builder` agent's /// own `max_iterations` caps its loop, but a hung LLM/tool call must never let /// the RPC block indefinitely. -const FLOW_BUILD_TIMEOUT_SECS: u64 = 300; +/// +/// Matches [`FLOW_RUN_TIMEOUT_SECS`] (600s): since the (B31) fix below actually +/// applies the builder's `effective_max_iterations()` (50, not the global +/// default of 10) to this path, a worst-case run at ~10s/iteration can take up +/// to ~500s — the old 300s bound would have clipped a legitimate long build +/// before the iteration cap ever got a chance to. +const FLOW_BUILD_TIMEOUT_SECS: u64 = 600; /// Tools stripped from the `workflow_builder` belt on the direct `flows_build` /// RPC path (issue #4593). @@ -3301,6 +3307,61 @@ fn restrict_builder_toolset(agent: &mut crate::openhuman::agent::Agent) { agent.hide_tools(FLOWS_BUILD_HIDDEN_TOOLS); } +/// (B31) Returns a clone of `config` with `agent.max_tool_iterations` +/// overridden to the `workflow_builder` [`AgentDefinition`](crate::openhuman::agent::harness::definition::AgentDefinition)'s +/// [`effective_max_iterations()`](crate::openhuman::agent::harness::definition::AgentDefinition::effective_max_iterations), +/// for use by [`Agent::from_config_for_agent`](crate::openhuman::agent::Agent::from_config_for_agent) +/// in [`flows_build`]. +/// +/// Two independent iteration-cap systems exist in the harness: the per-agent +/// `AgentDefinition.max_iterations`/`iteration_policy` (`workflow_builder`'s +/// `agent.toml` declares `iteration_policy = "extended"`, so its effective cap +/// is `max(max_iterations, EXTENDED_MAX_TOOL_ITERATIONS)` = 50), and the +/// GLOBAL `Config.agent.max_tool_iterations` (default 10). Only the +/// **sub-agent runner** path (`spawn_subagent`, used when `workflow_builder` +/// is invoked as a chat delegate) applies the definition's effective cap. +/// `flows_build` calls `Agent::from_config_for_agent` directly — the session +/// builder stamps `config.agent.clone()` onto the session unconditionally, so +/// without this override the builder silently got the global default (10) +/// instead of the 50 its own `agent.toml` intends, burning its entire budget +/// on a handful of dry-run cycles for anything past a trivial graph. +/// +/// A missing registry or missing `workflow_builder` definition (registry not +/// yet initialised, a stripped-down test registry) falls back to `config` +/// unchanged — same behavior as before this fix, never a hard failure. +fn apply_builder_iteration_cap(config: &Config) -> Config { + let mut build_config = config.clone(); + let Some(reg) = crate::openhuman::agent::harness::AgentDefinitionRegistry::global() else { + tracing::warn!( + target: "flows", + "[flows] flows_build: agent registry not initialised; falling back to global \ + max_tool_iterations={}", + config.agent.max_tool_iterations + ); + return build_config; + }; + let Some(def) = reg.get("workflow_builder") else { + tracing::warn!( + target: "flows", + "[flows] flows_build: workflow_builder definition not found in registry; falling \ + back to global max_tool_iterations={}", + config.agent.max_tool_iterations + ); + return build_config; + }; + let effective = def.effective_max_iterations(); + tracing::debug!( + target: "flows", + toml_max_iterations = def.max_iterations, + effective_max_iterations = effective, + global_default = config.agent.max_tool_iterations, + "[flows] flows_build: overriding max_tool_iterations from the workflow_builder agent \ + definition (the session path does not apply effective_max_iterations() by default)" + ); + build_config.agent.max_tool_iterations = effective; + build_config +} + /// Runs the `workflow_builder` agent for one authoring turn and returns its /// proposal, invoking it as a first-class backend agent (exactly like the Flow /// Scout `flows_discover`) rather than routing a hand-crafted delegate prompt @@ -3343,7 +3404,13 @@ pub async fn flows_build( crate::openhuman::agent::harness::AgentDefinitionRegistry::init_global(&config.workspace_dir) .map_err(|e| format!("failed to initialise agent registry: {e}"))?; - let mut agent = Agent::from_config_for_agent(config, "workflow_builder") + // (B31) See `apply_builder_iteration_cap`'s doc: the session path below + // otherwise stamps the GLOBAL `config.agent.max_tool_iterations` (10) + // instead of the `workflow_builder` definition's intended + // `effective_max_iterations()` (50). + let build_config = apply_builder_iteration_cap(config); + + let mut agent = Agent::from_config_for_agent(&build_config, "workflow_builder") .map_err(|e| format!("failed to build workflow_builder agent: {e:#}"))?; agent.set_agent_definition_name("workflow_builder".to_string()); diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index c9eb7b40ba..95f9939891 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -3156,6 +3156,51 @@ async fn flows_build_hides_the_live_run_tool_from_the_builder_belt() { } } +/// Regression for B31: `flows_build` must apply the `workflow_builder` +/// `AgentDefinition`'s `effective_max_iterations()` (50, from `agent.toml`'s +/// `iteration_policy = "extended"`), not the global `Config::default()` +/// `agent.max_tool_iterations` (10) — see `apply_builder_iteration_cap`'s doc. +#[tokio::test] +async fn flows_build_applies_the_builder_definitions_effective_iteration_cap() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + // Precondition: the global default really is lower than the definition's + // effective cap, otherwise this test can't distinguish the two. + assert_eq!(config.agent.max_tool_iterations, 10); + + crate::openhuman::agent::harness::AgentDefinitionRegistry::init_global(&config.workspace_dir) + .expect("agent registry init"); + let def = crate::openhuman::agent::harness::AgentDefinitionRegistry::global() + .expect("registry initialised") + .get("workflow_builder") + .expect("workflow_builder definition registered") + .clone(); + let expected = def.effective_max_iterations(); + assert_eq!( + expected, 50, + "workflow_builder's agent.toml is expected to declare iteration_policy = \"extended\", \ + yielding an effective cap of EXTENDED_MAX_TOOL_ITERATIONS (50)" + ); + + let build_config = apply_builder_iteration_cap(&config); + assert_eq!( + build_config.agent.max_tool_iterations, expected, + "flows_build's build_config must carry the definition's effective cap, not the global \ + default" + ); + assert_ne!( + build_config.agent.max_tool_iterations, config.agent.max_tool_iterations, + "sanity: the override must actually differ from the unmodified global config" + ); + + // End-to-end: the agent actually built for this path carries the override. + let agent = + crate::openhuman::agent::Agent::from_config_for_agent(&build_config, "workflow_builder") + .expect("build workflow_builder agent"); + assert_eq!(agent.agent_config().max_tool_iterations, expected); +} + // ───────────────────────────────────────────────────────────────────────────── // B23/B24 — condition node branch label must be on `from_port`, not `to_port` // ───────────────────────────────────────────────────────────────────────────── From 1d0b8a1b724a197f20a641534bd74cd059efa188 Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Tue, 14 Jul 2026 20:44:27 +0530 Subject: [PATCH 3/6] feat(flows): surface a "capped" signal + Continue building UX on cap-hit (B34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a flows_build turn hits the tool-call budget with no proposal yet, flows_build returned { proposal: null, assistant_text: "", error: null } — indistinguishable, in the response shape, from the agent voluntarily asking a clarifying question. The frontend rendered the checkpoint as a plain agent bubble with no signal that the turn was budget-capped and no one-click way to resume. Backend: add last_turn_hit_cap (session/types.rs), set from the tinyagents outcome right after it resolves in turn/core.rs (before any early return, so it always reflects the latest turn), and expose it via a new Agent::last_turn_hit_cap() accessor (runtime.rs). flows_build (ops.rs) reads it after the run and adds a `capped` field to its response JSON, true only when the turn hit the cap AND produced no proposal (a turn that squeezed out a proposal despite hitting the cap has nothing left to continue). Frontend: BuilderTurnResult gains `capped` (flowsApi.ts); useWorkflowBuilderChat exposes a `capped` state, reset at the start of every send() and set from the turn result so it never reflects a stale prior turn; WorkflowCopilotPanel renders a "Continue building" card (amber-bordered, matching the existing ocean proposal / coral error card pattern) when capped, with a button that routes through the same `submit` path a typed follow-up would — the existing pendingAskRef mechanism (a capped turn also has proposed === false) automatically carries the original ask forward so the resumed turn keeps full context. i18n: flows.copilot.cappedNotice / flows.copilot.continueBuilding added to en.ts with real (non-placeholder) translations in all 13 other locales; pnpm i18n:check passes with 0 missing/extra keys. Tests: Vitest coverage for the hook (capped set/cleared/reset-on-new-turn) and the panel (card renders only when capped && !proposal, click dispatches a follow-up turn). --- .../flows/WorkflowCopilotPanel.test.tsx | 68 +++++++++++++++++++ .../components/flows/WorkflowCopilotPanel.tsx | 36 ++++++++++ app/src/hooks/useWorkflowBuilderChat.test.ts | 57 ++++++++++++++++ app/src/hooks/useWorkflowBuilderChat.ts | 22 ++++++ app/src/lib/i18n/ar.ts | 3 + app/src/lib/i18n/bn.ts | 3 + app/src/lib/i18n/de.ts | 3 + app/src/lib/i18n/en.ts | 3 + app/src/lib/i18n/es.ts | 3 + app/src/lib/i18n/fr.ts | 3 + app/src/lib/i18n/hi.ts | 3 + app/src/lib/i18n/id.ts | 3 + app/src/lib/i18n/it.ts | 3 + app/src/lib/i18n/ko.ts | 3 + app/src/lib/i18n/pl.ts | 3 + app/src/lib/i18n/pt.ts | 3 + app/src/lib/i18n/ru.ts | 3 + app/src/lib/i18n/zh-CN.ts | 2 + app/src/services/api/flowsApi.ts | 20 +++++- .../agent/harness/session/builder/setters.rs | 1 + .../agent/harness/session/runtime.rs | 9 +++ .../agent/harness/session/turn/core.rs | 6 ++ src/openhuman/agent/harness/session/types.rs | 10 +++ src/openhuman/flows/ops.rs | 16 +++++ 24 files changed, 283 insertions(+), 3 deletions(-) diff --git a/app/src/components/flows/WorkflowCopilotPanel.test.tsx b/app/src/components/flows/WorkflowCopilotPanel.test.tsx index 705f342c5d..c32b29a769 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.test.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.test.tsx @@ -17,6 +17,7 @@ interface MockMessage { const hookState = vi.hoisted(() => ({ sending: false, proposal: null as WorkflowProposal | null, + capped: false, // Panel renders `displayMessages` (already interim-filtered upstream by // `useWorkflowBuilderChat`) — kept separate from `messages` in these tests // so a mismatch between the two proves the panel is reading the right field. @@ -51,6 +52,7 @@ describe('WorkflowCopilotPanel', () => { beforeEach(() => { hookState.sending = false; hookState.proposal = null; + hookState.capped = false; hookState.displayMessages = []; hookState.toolTimeline = []; hookState.liveResponse = ''; @@ -335,6 +337,72 @@ describe('WorkflowCopilotPanel', () => { expect(hookState.clearProposal).toHaveBeenCalledTimes(1); }); + it('B34: renders a "Continue building" card when the turn hit the iteration cap', () => { + hookState.capped = true; + render( + + ); + expect(screen.getByTestId('workflow-copilot-capped')).toBeInTheDocument(); + expect(screen.getByTestId('workflow-copilot-continue')).toBeInTheDocument(); + }); + + it('B34: does NOT render the capped card for a normal (non-capped) turn', () => { + hookState.capped = false; + render( + + ); + expect(screen.queryByTestId('workflow-copilot-capped')).not.toBeInTheDocument(); + }); + + it('B34: does not render the capped card while a proposal is pending, even if capped is stale-true', () => { + // Defense-in-depth: the server already scopes `capped` to `proposal === + // null`, but the panel re-checks `!proposal` itself too (see the JSX + // condition) in case a stale `capped=true` from a prior turn outlives a + // later turn's proposal. + hookState.capped = true; + hookState.proposal = proposalWith(['a', 'c']); + render( + + ); + expect(screen.queryByTestId('workflow-copilot-capped')).not.toBeInTheDocument(); + }); + + it('B34: clicking "Continue building" sends a follow-up turn', async () => { + hookState.capped = true; + render( + + ); + fireEvent.click(screen.getByTestId('workflow-copilot-continue')); + await waitFor(() => expect(hookState.send).toHaveBeenCalledTimes(1)); + const arg = hookState.send.mock.calls[0][0]; + expect(arg.request.mode).toBe('revise'); + expect(arg.request.graph).toEqual(baseGraph); + }); + it('auto-sends a repair turn once when opened with a repair seed', () => { render( { + void submit(t('flows.copilot.continueBuilding')); + }, [submit, t]); + const handleInputKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key === 'Enter' && !event.shiftKey && !isComposingTextRef.current) { @@ -513,6 +525,30 @@ export default function WorkflowCopilotPanel({ )} + + {/* (B34) The turn hit the agent's tool-call budget with no proposal + yet — distinguish this from a voluntary clarifying question (which + renders as a plain agent bubble above, no card) with an explicit + "ran out of steps" signal and a one-click resume. Never shown + alongside `sending` (a fresh turn already cleared `capped`) or a + proposal (mutually exclusive server-side — see `ops.rs`). */} + {capped && !sending && !proposal && ( +
+

{t('flows.copilot.cappedNotice')}

+
+ +
+
+ )}
diff --git a/app/src/hooks/useWorkflowBuilderChat.test.ts b/app/src/hooks/useWorkflowBuilderChat.test.ts index b865b1d983..20cf47c07a 100644 --- a/app/src/hooks/useWorkflowBuilderChat.test.ts +++ b/app/src/hooks/useWorkflowBuilderChat.test.ts @@ -56,6 +56,7 @@ const okResult = (over: Partial = {}): BuilderTurnResult => ( proposal: null, assistantText: 'done', error: null, + capped: false, ...over, }); @@ -129,6 +130,62 @@ describe('useWorkflowBuilderChat', () => { ); }); + it('B34: surfaces `capped` when the turn hit the iteration cap with no proposal', async () => { + buildWorkflow.mockResolvedValue(okResult({ capped: true, proposal: null })); + const { result } = renderHook(() => useWorkflowBuilderChat()); + expect(result.current.capped).toBe(false); + await act(async () => { + await result.current.send({ + displayText: 'build me something complex', + request: { mode: 'create', instruction: 'x' }, + }); + }); + expect(result.current.capped).toBe(true); + }); + + it('B34: does not surface `capped` for a normal turn (not capped)', async () => { + buildWorkflow.mockResolvedValue(okResult({ capped: false })); + const { result } = renderHook(() => useWorkflowBuilderChat()); + await act(async () => { + await result.current.send({ + displayText: 'hi', + request: { mode: 'create', instruction: 'x' }, + }); + }); + expect(result.current.capped).toBe(false); + }); + + it('B34: resets a stale `capped=true` at the start of a new turn even if the server sends capped again with a proposal', async () => { + // First turn: capped, no proposal. + buildWorkflow.mockResolvedValueOnce(okResult({ capped: true, proposal: null })); + const { result } = renderHook(() => useWorkflowBuilderChat()); + await act(async () => { + await result.current.send({ + displayText: 'build me something complex', + request: { mode: 'create', instruction: 'x' }, + }); + }); + expect(result.current.capped).toBe(true); + + // Second turn resolves with a proposal — `capped` must clear even if the + // (malformed/stale) server payload still says `capped: true`, since the + // hook re-checks `!result.proposal` itself, not just at the top of send(). + const proposal: WorkflowProposal = { + name: 'Digest', + graph: { nodes: [], edges: [] }, + requireApproval: true, + summary: { trigger: 'schedule', steps: [] }, + }; + buildWorkflow.mockResolvedValueOnce(okResult({ capped: true, proposal })); + await act(async () => { + await result.current.send({ + displayText: 'continue', + request: { mode: 'revise', instruction: 'continue' }, + }); + }); + expect(result.current.capped).toBe(false); + }); + it('appends the user turn locally but never the agent reply — onDone is the single authoritative path (B26)', async () => { buildWorkflow.mockResolvedValue(okResult({ assistantText: 'Here is your workflow.' })); const { result } = renderHook(() => useWorkflowBuilderChat()); diff --git a/app/src/hooks/useWorkflowBuilderChat.ts b/app/src/hooks/useWorkflowBuilderChat.ts index 8a7a4af3d8..fa2710243e 100644 --- a/app/src/hooks/useWorkflowBuilderChat.ts +++ b/app/src/hooks/useWorkflowBuilderChat.ts @@ -96,6 +96,16 @@ export interface UseWorkflowBuilderChat { sending: boolean; /** The latest proposal the agent returned on this thread, or `null`. */ proposal: WorkflowProposal | null; + /** + * `true` when the most recently settled turn paused because it hit the + * agent's tool-call budget with no proposal yet (B34) — the caller should + * render a "Continue building" affordance instead of treating + * `displayMessages`' latest agent bubble (the raw "Done so far / Next + * steps" checkpoint) as a normal reply or a clarifying question. Reset to + * `false` at the start of every new `send()` call, so it only ever + * reflects the most recent turn. + */ + capped: boolean; /** * The dedicated thread's FULL transcript (user + agent turns, including * between-tool narration bubbles), so a caller that needs the complete @@ -164,6 +174,7 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo const [threadId, setThreadId] = useState(seedThreadId ?? null); const [localSending, setLocalSending] = useState(false); const [error, setError] = useState(null); + const [capped, setCapped] = useState(false); // Tracks a thread id this hook created itself via `send()`'s `createNewThread` // call — as opposed to one that arrived from `seedThreadId` because a caller // (e.g. `WorkflowCopilotPanel`) reports every `threadId` change back up via @@ -295,6 +306,10 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo } setLocalSending(true); setError(null); + // A fresh turn supersedes any prior cap-hit signal, same as the + // proposal-clearing dispatch below — `capped` must only ever reflect + // this turn, not a stale one. + setCapped(false); let targetThreadId = threadId; let proposed = false; try { @@ -344,6 +359,12 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo } else if (result.error) { setError(result.error); } + // (B34) Surface the cap-hit signal so the panel can render a + // "Continue building" card instead of the raw checkpoint text as a + // normal reply. Scoped to `!result.proposal` server-side already + // (`ops.rs`'s `capped` field), but re-checked here too — a proposal + // means there's nothing left to "continue". + setCapped(result.capped && !result.proposal); // Note: no local fallback append for `result.assistantText` here (B26). // `ChatRuntimeProvider.onDone` is the SINGLE authoritative path that // persists the assistant's reply on the turn's `chat_done` event — the @@ -392,6 +413,7 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo threadId, sending, proposal, + capped, messages, displayMessages, toolTimeline, diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 5d7bafd3b3..6f2ddf57e3 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4002,6 +4002,9 @@ const messages: TranslationMap = { 'flows.copilot.tool.dryRunning': 'تجربة تشغيل سير العمل…', 'flows.copilot.tool.saving': 'حفظ سير العمل…', 'flows.copilot.tool.usingTools': 'استخدام الأدوات…', + 'flows.copilot.cappedNotice': + 'نفد عدد الخطوات المتاحة للمنشئ قبل الانتهاء من إعداد سير العمل هذا. يمكنه المتابعة من حيث توقف تمامًا.', + 'flows.copilot.continueBuilding': 'متابعة الإنشاء', 'flows.list.view': 'عرض سير العمل', 'flows.list.export': 'تصدير', 'flows.list.exported': 'تم تصدير سير العمل', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 75f87bd5d6..66ec9d4547 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4100,6 +4100,9 @@ const messages: TranslationMap = { 'flows.copilot.tool.dryRunning': 'ওয়ার্কফ্লো ড্রাই-রান করা হচ্ছে…', 'flows.copilot.tool.saving': 'ওয়ার্কফ্লো সংরক্ষণ করা হচ্ছে…', 'flows.copilot.tool.usingTools': 'টুল ব্যবহার করা হচ্ছে…', + 'flows.copilot.cappedNotice': + 'এই ওয়ার্কফ্লো শেষ করার আগেই বিল্ডারের ধাপ ফুরিয়ে গেছে। এটি যেখানে থেমেছিল সেখান থেকেই আবার চালিয়ে যেতে পারে।', + 'flows.copilot.continueBuilding': 'নির্মাণ চালিয়ে যান', 'flows.list.view': 'ওয়ার্কফ্লো দেখুন', 'flows.list.export': 'রপ্তানি', 'flows.list.exported': 'ওয়ার্কফ্লো রপ্তানি হয়েছে', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 886236acdd..173084de69 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4218,6 +4218,9 @@ const messages: TranslationMap = { 'flows.copilot.tool.dryRunning': 'Workflow wird testweise ausgeführt…', 'flows.copilot.tool.saving': 'Workflow wird gespeichert…', 'flows.copilot.tool.usingTools': 'Werkzeuge werden verwendet…', + 'flows.copilot.cappedNotice': + 'Dem Builder sind vor Abschluss dieses Workflows die Schritte ausgegangen. Er kann genau dort weitermachen, wo er aufgehört hat.', + 'flows.copilot.continueBuilding': 'Weiter erstellen', 'flows.list.view': 'Workflow anzeigen', 'flows.list.export': 'Exportieren', 'flows.list.exported': 'Workflow exportiert', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 9b3a66d417..2970fd9c75 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -4787,6 +4787,9 @@ const en: TranslationMap = { 'flows.copilot.tool.dryRunning': 'Dry-running workflow…', 'flows.copilot.tool.saving': 'Saving workflow…', 'flows.copilot.tool.usingTools': 'Using tools…', + 'flows.copilot.cappedNotice': + 'The builder ran out of steps before finishing this workflow. It can pick up right where it left off.', + 'flows.copilot.continueBuilding': 'Continue building', // ── Workflow Canvas (issue B5b.1) — the read-only graph view of a saved // flow at /flows/:id. `flows.nodeKind.*` labels the 12 tinyflows node diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index d9e981ff33..1af8741159 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4172,6 +4172,9 @@ const messages: TranslationMap = { 'flows.copilot.tool.dryRunning': 'Ejecutando prueba del flujo de trabajo…', 'flows.copilot.tool.saving': 'Guardando flujo de trabajo…', 'flows.copilot.tool.usingTools': 'Usando herramientas…', + 'flows.copilot.cappedNotice': + 'El generador se quedó sin pasos antes de terminar este flujo de trabajo. Puede continuar justo donde lo dejó.', + 'flows.copilot.continueBuilding': 'Continuar creando', 'flows.list.view': 'Ver flujo de trabajo', 'flows.list.export': 'Exportar', 'flows.list.exported': 'Flujo de trabajo exportado', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 3daf295423..7765d53f32 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4200,6 +4200,9 @@ const messages: TranslationMap = { 'flows.copilot.tool.dryRunning': 'Exécution d’essai du workflow…', 'flows.copilot.tool.saving': 'Enregistrement du workflow…', 'flows.copilot.tool.usingTools': 'Utilisation d’outils…', + 'flows.copilot.cappedNotice': + 'Le générateur a épuisé ses étapes avant de terminer ce workflow. Il peut reprendre exactement là où il s’est arrêté.', + 'flows.copilot.continueBuilding': 'Continuer la création', 'flows.list.view': 'Voir le workflow', 'flows.list.export': 'Exporter', 'flows.list.exported': 'Workflow exporté', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index dd7fe5d902..c43e537b9b 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4098,6 +4098,9 @@ const messages: TranslationMap = { 'flows.copilot.tool.dryRunning': 'वर्कफ़्लो का ड्राई-रन किया जा रहा है…', 'flows.copilot.tool.saving': 'वर्कफ़्लो सहेजा जा रहा है…', 'flows.copilot.tool.usingTools': 'टूल का उपयोग किया जा रहा है…', + 'flows.copilot.cappedNotice': + 'इस वर्कफ़्लो को पूरा करने से पहले बिल्डर के चरण समाप्त हो गए। यह वहीं से आगे जारी रख सकता है जहाँ इसने छोड़ा था।', + 'flows.copilot.continueBuilding': 'निर्माण जारी रखें', 'flows.list.view': 'वर्कफ़्लो देखें', 'flows.list.export': 'निर्यात', 'flows.list.exported': 'वर्कफ़्लो निर्यात किया गया', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 4052a84d26..6d1a64273b 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4115,6 +4115,9 @@ const messages: TranslationMap = { 'flows.copilot.tool.dryRunning': 'Menjalankan uji coba alur kerja…', 'flows.copilot.tool.saving': 'Menyimpan alur kerja…', 'flows.copilot.tool.usingTools': 'Menggunakan alat…', + 'flows.copilot.cappedNotice': + 'Builder kehabisan langkah sebelum menyelesaikan alur kerja ini. Builder dapat melanjutkan tepat dari titik terakhir.', + 'flows.copilot.continueBuilding': 'Lanjutkan membangun', 'flows.list.view': 'Lihat alur kerja', 'flows.list.export': 'Ekspor', 'flows.list.exported': 'Alur kerja diekspor', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 06828119a3..f82b33debc 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4169,6 +4169,9 @@ const messages: TranslationMap = { 'flows.copilot.tool.dryRunning': 'Esecuzione di prova del flusso di lavoro…', 'flows.copilot.tool.saving': 'Salvataggio del flusso di lavoro…', 'flows.copilot.tool.usingTools': 'Utilizzo degli strumenti…', + 'flows.copilot.cappedNotice': + 'Il builder ha esaurito i passaggi prima di completare questo flusso di lavoro. Può riprendere esattamente da dove si era interrotto.', + 'flows.copilot.continueBuilding': 'Continua a costruire', 'flows.list.view': 'Visualizza flusso di lavoro', 'flows.list.export': 'Esporta', 'flows.list.exported': 'Flusso di lavoro esportato', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index fed6143361..b1da3b16df 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4055,6 +4055,9 @@ const messages: TranslationMap = { 'flows.copilot.tool.dryRunning': '워크플로 시험 실행 중…', 'flows.copilot.tool.saving': '워크플로 저장 중…', 'flows.copilot.tool.usingTools': '도구 사용 중…', + 'flows.copilot.cappedNotice': + '빌더가 이 워크플로를 완료하기 전에 단계가 소진되었습니다. 중단된 지점부터 바로 이어서 진행할 수 있습니다.', + 'flows.copilot.continueBuilding': '계속 빌드하기', 'flows.list.view': '워크플로 보기', 'flows.list.export': '내보내기', 'flows.list.exported': '워크플로를 내보냈습니다', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index ce57182802..15bbed6fe4 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4153,6 +4153,9 @@ const messages: TranslationMap = { 'flows.copilot.tool.dryRunning': 'Testowe uruchamianie przepływu pracy…', 'flows.copilot.tool.saving': 'Zapisywanie przepływu pracy…', 'flows.copilot.tool.usingTools': 'Używanie narzędzi…', + 'flows.copilot.cappedNotice': + 'Kreatorowi zabrakło kroków przed ukończeniem tego przepływu pracy. Może kontynuować dokładnie od miejsca, w którym skończył.', + 'flows.copilot.continueBuilding': 'Kontynuuj tworzenie', 'flows.list.view': 'Wyświetl przepływ pracy', 'flows.list.export': 'Eksportuj', 'flows.list.exported': 'Wyeksportowano przepływ pracy', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 3d89a22546..bea5fc0f16 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4164,6 +4164,9 @@ const messages: TranslationMap = { 'flows.copilot.tool.dryRunning': 'Executando teste do fluxo de trabalho…', 'flows.copilot.tool.saving': 'Salvando fluxo de trabalho…', 'flows.copilot.tool.usingTools': 'Usando ferramentas…', + 'flows.copilot.cappedNotice': + 'O construtor ficou sem etapas antes de concluir este fluxo de trabalho. Ele pode continuar exatamente de onde parou.', + 'flows.copilot.continueBuilding': 'Continuar a construir', 'flows.list.view': 'Ver fluxo de trabalho', 'flows.list.export': 'Exportar', 'flows.list.exported': 'Fluxo de trabalho exportado', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 992715873d..d4f89c64c4 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4136,6 +4136,9 @@ const messages: TranslationMap = { 'flows.copilot.tool.dryRunning': 'Выполняется пробный запуск рабочего процесса…', 'flows.copilot.tool.saving': 'Сохранение рабочего процесса…', 'flows.copilot.tool.usingTools': 'Использование инструментов…', + 'flows.copilot.cappedNotice': + 'У конструктора закончились шаги до завершения этого рабочего процесса. Он может продолжить точно с того места, где остановился.', + 'flows.copilot.continueBuilding': 'Продолжить создание', 'flows.list.view': 'Просмотреть рабочий процесс', 'flows.list.export': 'Экспорт', 'flows.list.exported': 'Рабочий процесс экспортирован', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 5345a471cf..aed31e8f22 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -3884,6 +3884,8 @@ const messages: TranslationMap = { 'flows.copilot.tool.dryRunning': '正在试运行工作流…', 'flows.copilot.tool.saving': '正在保存工作流…', 'flows.copilot.tool.usingTools': '正在使用工具…', + 'flows.copilot.cappedNotice': '构建器在完成此工作流之前用完了步骤,可以从中断的地方继续。', + 'flows.copilot.continueBuilding': '继续构建', 'flows.list.view': '查看工作流', 'flows.list.export': '导出', 'flows.list.exported': '工作流已导出', diff --git a/app/src/services/api/flowsApi.ts b/app/src/services/api/flowsApi.ts index 27620f1594..31bc2e8d03 100644 --- a/app/src/services/api/flowsApi.ts +++ b/app/src/services/api/flowsApi.ts @@ -619,14 +619,22 @@ export interface BuilderTurnResult { assistantText: string; /** A run error, if the turn failed but a prior proposal was still captured. */ error: string | null; + /** + * `true` when the turn paused because it hit the agent's tool-call budget + * (`max_tool_iterations`) with no proposal yet — as opposed to the agent + * voluntarily asking a clarifying question or finishing. `assistantText` is + * a "Done so far / Next steps" checkpoint in this case; the UI should offer + * a "Continue building" action rather than rendering it as a normal reply. + */ + capped: boolean; } /** - * The `workflow_builder` agent can take up to ~300s server-side + * The `workflow_builder` agent can take up to ~600s server-side * (`FLOW_BUILD_TIMEOUT_SECS` in `src/openhuman/flows/ops.rs`); match it so a slow * authoring turn doesn't time out client-side while the agent is still working. */ -const FLOW_BUILD_TIMEOUT_MS = 310_000; +const FLOW_BUILD_TIMEOUT_MS = 610_000; /** * Map a raw `{ type: 'workflow_proposal', … }` payload (from the agent's @@ -702,12 +710,18 @@ export async function buildWorkflow( proposal: unknown; assistant_text: string; error: string | null; + capped?: boolean; }>(response); - log('buildWorkflow: response hasProposal=%s', result.proposal != null); + log( + 'buildWorkflow: response hasProposal=%s capped=%s', + result.proposal != null, + result.capped ?? false + ); return { proposal: mapWorkflowProposal(result.proposal), assistantText: result.assistant_text ?? '', error: result.error ?? null, + capped: result.capped ?? false, }; } diff --git a/src/openhuman/agent/harness/session/builder/setters.rs b/src/openhuman/agent/harness/session/builder/setters.rs index 81bd46b318..b3cea8dc09 100644 --- a/src/openhuman/agent/harness/session/builder/setters.rs +++ b/src/openhuman/agent/harness/session/builder/setters.rs @@ -496,6 +496,7 @@ impl AgentBuilder { last_memory_context: None, last_turn_citations: Vec::new(), last_turn_usage_totals: None, + last_turn_hit_cap: false, history: Vec::new(), post_turn_hooks: self.post_turn_hooks, learning_enabled: self.learning_enabled, diff --git a/src/openhuman/agent/harness/session/runtime.rs b/src/openhuman/agent/harness/session/runtime.rs index 9284414fe7..0697804943 100644 --- a/src/openhuman/agent/harness/session/runtime.rs +++ b/src/openhuman/agent/harness/session/runtime.rs @@ -387,6 +387,15 @@ impl Agent { self.last_turn_usage_totals.take() } + /// Whether the most recently completed [`Self::turn`] / [`Self::run_single`] + /// paused because it hit `max_tool_iterations`, rather than finishing + /// naturally (see the field doc on `last_turn_hit_cap`). `false` before + /// any turn has run. Not draining — unlike the usage totals above, a + /// caller may reasonably check this more than once per turn. + pub fn last_turn_hit_cap(&self) -> bool { + self.last_turn_hit_cap + } + // ───────────────────────────────────────────────────────────────── // Static helpers for turn parsing + telemetry // ───────────────────────────────────────────────────────────────── diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs index f83ab8e682..ecb1dca6b1 100644 --- a/src/openhuman/agent/harness/session/turn/core.rs +++ b/src/openhuman/agent/harness/session/turn/core.rs @@ -973,6 +973,12 @@ impl Agent { .await; let outcome = outcome?; + // Record whether this turn paused at the tool-call cap (vs. finishing + // naturally) BEFORE anything below can early-return, so a caller + // inspecting `last_turn_hit_cap()` after `run_single` always reflects + // this turn, never a stale value from a prior one. + self.last_turn_hit_cap = outcome.hit_cap; + // The stamped user turn is already in `self.history` (pushed by `turn()`), // so append only the structured messages this turn produced — assistant // tool calls + tool results + (for a clean finish) the final assistant — diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index f0c58dc8e6..10bac4625a 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -80,6 +80,16 @@ pub struct Agent { /// the first turn completes. pub(super) last_turn_usage_totals: Option, + /// Whether the most recent turn's tinyagents loop paused because it hit + /// `max_tool_iterations` (`TinyagentsTurnOutcome::hit_cap`), rather than + /// finishing naturally. `false` until the first turn completes, and reset + /// on every subsequent turn — so it only ever reflects the LAST turn, not + /// "any turn ever". Consumed by callers that run a single headless turn + /// via [`run_single`](super::runtime) (e.g. `flows_build`) and need to + /// distinguish "the agent paused mid-work" from "the agent asked a + /// question" or "the agent finished" — `run_single` only returns the + /// checkpoint/final text, with no other signal for which case occurred. + pub(super) last_turn_hit_cap: bool, pub(super) history: Vec, pub(super) post_turn_hooks: Vec>, pub(super) learning_enabled: bool, diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index d4a309812e..61ec81c24e 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -3494,9 +3494,24 @@ pub async fn flows_build( } } + // (B34) Whether this turn paused because it hit `max_tool_iterations` + // rather than finishing naturally (asking a question, or proposing). A + // capped turn with no proposal renders a raw checkpoint ("Done so far / + // Next steps") that's indistinguishable, in the response shape alone, + // from the agent voluntarily asking a clarifying question — `capped` + // gives the frontend the explicit signal to render a "Continue building" + // card instead. Scoped to `proposal.is_none()`: a turn that hit the cap + // but still squeezed out a proposal (the checkpoint fires before the + // final `propose_workflow` call in that ordering) has nothing left to + // continue. + let hit_cap = agent.last_turn_hit_cap(); + let capped = hit_cap && proposal.is_none(); + tracing::info!( target: "flows", has_proposal = proposal.is_some(), + hit_cap, + capped, "[flows] flows_build: workflow_builder turn complete" ); Ok(RpcOutcome::single_log( @@ -3504,6 +3519,7 @@ pub async fn flows_build( "proposal": proposal, "assistant_text": assistant_text, "error": run_error, + "capped": capped, }), "workflow builder turn complete", )) From 22a92fd769499f3a7648915ddf3088261ed8009d Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 14 Jul 2026 20:57:35 +0530 Subject: [PATCH 4/6] i18n(flows): reword capped-notice to avoid 'steps' collision (de/es/it/pt/ru) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review: in these five locales the cappedNotice translated 'ran out of steps' with a word that literally means workflow steps/nodes (Schritte/pasos/passaggi/etapas/шаги), which reads as if the workflow graph is incomplete rather than the builder hitting its turn/iteration budget. Reword to convey 'reached its limit' — accurate, non-technical, and free of the workflow-domain collision. EN source and unflagged locales unchanged. --- app/src/lib/i18n/de.ts | 2 +- app/src/lib/i18n/es.ts | 2 +- app/src/lib/i18n/it.ts | 2 +- app/src/lib/i18n/pt.ts | 2 +- app/src/lib/i18n/ru.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 173084de69..813f410d85 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4219,7 +4219,7 @@ const messages: TranslationMap = { 'flows.copilot.tool.saving': 'Workflow wird gespeichert…', 'flows.copilot.tool.usingTools': 'Werkzeuge werden verwendet…', 'flows.copilot.cappedNotice': - 'Dem Builder sind vor Abschluss dieses Workflows die Schritte ausgegangen. Er kann genau dort weitermachen, wo er aufgehört hat.', + 'Der Builder hat sein Limit erreicht, bevor dieser Workflow fertig war. Er kann genau dort weitermachen, wo er aufgehört hat.', 'flows.copilot.continueBuilding': 'Weiter erstellen', 'flows.list.view': 'Workflow anzeigen', 'flows.list.export': 'Exportieren', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 1af8741159..83b3e8f7a2 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4173,7 +4173,7 @@ const messages: TranslationMap = { 'flows.copilot.tool.saving': 'Guardando flujo de trabajo…', 'flows.copilot.tool.usingTools': 'Usando herramientas…', 'flows.copilot.cappedNotice': - 'El generador se quedó sin pasos antes de terminar este flujo de trabajo. Puede continuar justo donde lo dejó.', + 'El generador alcanzó su límite antes de terminar este flujo de trabajo. Puede continuar justo donde lo dejó.', 'flows.copilot.continueBuilding': 'Continuar creando', 'flows.list.view': 'Ver flujo de trabajo', 'flows.list.export': 'Exportar', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index f82b33debc..f7fab61f0b 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4170,7 +4170,7 @@ const messages: TranslationMap = { 'flows.copilot.tool.saving': 'Salvataggio del flusso di lavoro…', 'flows.copilot.tool.usingTools': 'Utilizzo degli strumenti…', 'flows.copilot.cappedNotice': - 'Il builder ha esaurito i passaggi prima di completare questo flusso di lavoro. Può riprendere esattamente da dove si era interrotto.', + 'Il builder ha raggiunto il suo limite prima di completare questo flusso di lavoro. Può riprendere esattamente da dove si era interrotto.', 'flows.copilot.continueBuilding': 'Continua a costruire', 'flows.list.view': 'Visualizza flusso di lavoro', 'flows.list.export': 'Esporta', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index bea5fc0f16..8151b6788d 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4165,7 +4165,7 @@ const messages: TranslationMap = { 'flows.copilot.tool.saving': 'Salvando fluxo de trabalho…', 'flows.copilot.tool.usingTools': 'Usando ferramentas…', 'flows.copilot.cappedNotice': - 'O construtor ficou sem etapas antes de concluir este fluxo de trabalho. Ele pode continuar exatamente de onde parou.', + 'O construtor atingiu o seu limite antes de concluir este fluxo de trabalho. Ele pode continuar exatamente de onde parou.', 'flows.copilot.continueBuilding': 'Continuar a construir', 'flows.list.view': 'Ver fluxo de trabalho', 'flows.list.export': 'Exportar', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index d4f89c64c4..9408005f00 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4137,7 +4137,7 @@ const messages: TranslationMap = { 'flows.copilot.tool.saving': 'Сохранение рабочего процесса…', 'flows.copilot.tool.usingTools': 'Использование инструментов…', 'flows.copilot.cappedNotice': - 'У конструктора закончились шаги до завершения этого рабочего процесса. Он может продолжить точно с того места, где остановился.', + 'Конструктор достиг своего предела до завершения этого рабочего процесса. Он может продолжить точно с того места, где остановился.', 'flows.copilot.continueBuilding': 'Продолжить создание', 'flows.list.view': 'Просмотреть рабочий процесс', 'flows.list.export': 'Экспорт', From 216e98baf55404ecc47973aff8fbf03e7b1f660f Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Tue, 14 Jul 2026 21:08:53 +0530 Subject: [PATCH 5/6] fix(flows): accurate capped-turn copy + verify Continue resumes on-draft (#4865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit (5 locales, same issue): the capped-turn notice said the builder "ran out of steps" — confusing against graph nodes, which are also called "steps" elsewhere in the UI. Reword to name the real condition: the builder hit its iteration limit before producing a proposal. Fixed en.ts first, then retranslated all 14 locales. Codex (WorkflowCopilotPanel.tsx:342): verified "Continue building" already carries the CURRENT draft graph + the existing flowId through a `revise` turn (never a blank `create` restart) — `flows_build` has no server-side session/tool-history checkpoint to resume (a fresh `workflow_builder` agent runs per RPC), so this was never a literal seamless resume. What actually carries forward is the live draft graph (untouched by a capped turn, since revise_workflow/propose_workflow never persist without reaching this panel) plus the accumulated instruction text via `pendingAskRef`. Reworded the capped notice and the surrounding code comments to say "continuing builds from the current draft" instead of implying continuity the architecture doesn't provide, and added a regression test asserting the Continue turn carries `flowId`. No Rust changes — flows_build's revise-mode wiring was already correct. --- .../flows/WorkflowCopilotPanel.test.tsx | 26 +++++++++++++++++++ .../components/flows/WorkflowCopilotPanel.tsx | 25 +++++++++++++++--- app/src/lib/i18n/ar.ts | 2 +- app/src/lib/i18n/bn.ts | 2 +- app/src/lib/i18n/de.ts | 2 +- app/src/lib/i18n/en.ts | 2 +- app/src/lib/i18n/es.ts | 2 +- app/src/lib/i18n/fr.ts | 2 +- app/src/lib/i18n/hi.ts | 2 +- app/src/lib/i18n/id.ts | 2 +- app/src/lib/i18n/it.ts | 2 +- app/src/lib/i18n/ko.ts | 2 +- app/src/lib/i18n/pl.ts | 2 +- app/src/lib/i18n/pt.ts | 2 +- app/src/lib/i18n/ru.ts | 2 +- app/src/lib/i18n/zh-CN.ts | 3 ++- 16 files changed, 62 insertions(+), 18 deletions(-) diff --git a/app/src/components/flows/WorkflowCopilotPanel.test.tsx b/app/src/components/flows/WorkflowCopilotPanel.test.tsx index c32b29a769..0fe3fdea73 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.test.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.test.tsx @@ -403,6 +403,32 @@ describe('WorkflowCopilotPanel', () => { expect(arg.request.graph).toEqual(baseGraph); }); + // Codex review on #4865: "Continue building" must resume ON the current + // draft — a `revise` turn over the EXISTING `flowId`, never a blank/`create` + // restart — since `flows_build` spins up a fresh `workflow_builder` agent + // per RPC with no server-side session/checkpoint to resume. Carrying the + // live `graph` + `flowId` is what makes "Continue" a correct, working + // continuation instead of an empty restart. + it('B34: "Continue building" carries the current flowId, not a blank restart', async () => { + hookState.capped = true; + render( + + ); + fireEvent.click(screen.getByTestId('workflow-copilot-continue')); + await waitFor(() => expect(hookState.send).toHaveBeenCalledTimes(1)); + const arg = hookState.send.mock.calls[0][0]; + expect(arg.request.mode).toBe('revise'); + expect(arg.request.flowId).toBe('flow-123'); + expect(arg.request.graph).toEqual(baseGraph); + }); + it('auto-sends a repair turn once when opened with a repair seed', () => { render( { void submit(t('flows.copilot.continueBuilding')); }, [submit, t]); @@ -526,12 +540,15 @@ export default function WorkflowCopilotPanel({
)} - {/* (B34) The turn hit the agent's tool-call budget with no proposal + {/* (B34) The turn hit the agent's iteration limit with no proposal yet — distinguish this from a voluntary clarifying question (which renders as a plain agent bubble above, no card) with an explicit - "ran out of steps" signal and a one-click resume. Never shown - alongside `sending` (a fresh turn already cleared `capped`) or a - proposal (mutually exclusive server-side — see `ops.rs`). */} + "reached its iteration limit" signal and a one-click resume that + continues building from the current draft (see `continueBuilding` + above for why this is accurate rather than a seamless resume). + Never shown alongside `sending` (a fresh turn already cleared + `capped`) or a proposal (mutually exclusive server-side — see + `ops.rs`). */} {capped && !sending && !proposal && (
Date: Tue, 14 Jul 2026 21:35:03 +0530 Subject: [PATCH 6/6] test(flows): update buildWorkflow timeout assertions to 610s (B31) The B31 fix bumped FLOW_BUILD_TIMEOUT_MS 310_000 -> 610_000 (matching the backend FLOW_BUILD_TIMEOUT_SECS 300 -> 600), but two buildWorkflow test assertions in flowsApi.test.ts still expected the old 310_000. Discover tests correctly stay at 310_000 (FLOW_DISCOVER unchanged). --- app/src/services/api/flowsApi.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/services/api/flowsApi.test.ts b/app/src/services/api/flowsApi.test.ts index 72c674fb86..d35b131544 100644 --- a/app/src/services/api/flowsApi.test.ts +++ b/app/src/services/api/flowsApi.test.ts @@ -393,7 +393,7 @@ describe('flowsApi', () => { error: null, failing_node_ids: [], }, - timeoutMs: 310_000, + timeoutMs: 610_000, }); expect(result.assistantText).toBe('here you go'); expect(result.proposal?.name).toBe('Digest'); @@ -419,7 +419,7 @@ describe('flowsApi', () => { failing_node_ids: [], thread_id: 'builder-thread-9', }, - timeoutMs: 310_000, + timeoutMs: 610_000, }); }); });