Skip to content

docs(agent-workflows): streaming invoke for reliable multi-tool output - #5003

Closed
mmabrouk wants to merge 1 commit into
big-agentsfrom
docs/agent-streaming-invoke
Closed

docs(agent-workflows): streaming invoke for reliable multi-tool output#5003
mmabrouk wants to merge 1 commit into
big-agentsfrom
docs/agent-streaming-invoke

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Jul 1, 2026

Copy link
Copy Markdown
Member

What

Design docs only (no code). Second-round revision of the streaming-invoke workspace under docs/design/agent-workflows/projects/builder-agent-reliability/streaming-invoke/, per the review on this PR. Now six files (adds approval-boundary.md).

The direction (revised)

Use streaming everywhere the full turn matters, and leave batch alone.

  • Batch is unchanged. _agent_batch keeps returning one final assistant message (services/oss/src/agent/app.py:303-321). The earlier batch-coalescing proposal is dropped.
  • The external client streams to get the whole turn (already shipped in the lab kit test-agent.sh).
  • The platform's own invoke must stream too. When Agenta invokes an agent from inside the platform, it sends Accept: application/json today and gets the same partial output. The result-consuming batch paths are the workflow/agent-as-tool path (api/oss/src/apis/fastapi/tools/router.py:1306) and the evaluations runtime (api/oss/src/core/evaluations/runtime/adapters.py:104,508). Triggers/schedules and session-respond run detached in production, not batch. Proposal: a draining invoke_workflow_streaming variant on WorkflowsService, applied to workflow-as-tool first.

New page: approval-boundary.md

An auto-approved run stops at the tool gate. HITLResponder.onPermission returns park whenever a session id is present, before it consults the auto policy (services/agent/src/responder.ts:257), and the SDK mints a sessionId for every invoke, so auto never auto-approves in-band. The turn ends at the gate with stopReason: "paused"; the terminal tool's tool_result never streams. The playground hides this by auto-resending on the park (useChat + sendAutomaticallyWhen). Introduced in commit b109cc51ef (2026-06-25). Verdict: bug. Recommended fix at responder.ts:254-259, with an ask-disposition follow-up so the playground still prompts.

Built via temp-index commit-tree on origin/big-agents (zero working-tree touch). Diff is exactly the six doc files.

https://claude.ai/code/session_01WSp2LqKrEtXnm2fsPWuQWa

@vercel

vercel Bot commented Jul 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview, Comment Jul 3, 2026 9:44am

Request Review

@mmabrouk mmabrouk added the needs-review Agent updated; awaiting Mahmoud's review label Jul 1, 2026
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 08a5ef83-4a6f-408b-9aeb-a73c00736607

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds five design documents about streaming invoke reliability, describing the batch-versus-streaming output gap, the approval-boundary failure mode, and the proposed platform-side draining path. It also records rollout order, test coverage, and current review status.

Changes

Streaming invoke design documentation

Layer / File(s) Summary
README overview and doc index
docs/design/agent-workflows/projects/builder-agent-reliability/streaming-invoke/README.md
Introduces the streaming reliability problem, the proposed direction, related documents, and current status.
Problem reproduction and protocol behavior
docs/design/agent-workflows/projects/builder-agent-reliability/streaming-invoke/context.md, docs/design/agent-workflows/projects/builder-agent-reliability/streaming-invoke/research.md
Describes the batch/streaming mismatch, NDJSON event flow, client and platform invoke behavior, and the output model and negotiation rules.
Approval-boundary analysis
docs/design/agent-workflows/projects/builder-agent-reliability/streaming-invoke/approval-boundary.md
Details the gated SEND_MESSAGE stop, parking and pause behavior, the frontend resume path, the regression timeline, and the proposed responder-side fix.
Platform rollout plan and status
docs/design/agent-workflows/projects/builder-agent-reliability/streaming-invoke/plan.md, docs/design/agent-workflows/projects/builder-agent-reliability/streaming-invoke/status.md
Defines the draining WorkflowsService invoke path, rollout order, test matrix, open questions, live evidence, and next steps.

Estimated code review effort: 1 (Trivial) | ~5 minutes

Possibly related PRs

  • Agenta-AI/agenta#4859: Addresses the same approval-boundary HITL deny/terminal tool_result gap documented here.
  • Agenta-AI/agenta#4897: Also targets the parked approval flow that prevents terminal tool output from appearing in-stream.
  • Agenta-AI/agenta#4925: Changes the responder/permission pipeline around the same approval boundary described in these docs.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: design docs for streaming invoke reliability and multi-tool output.
Description check ✅ Passed The description is directly about the same documentation-only streaming-invoke design changes and the new approval-boundary note.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/agent-streaming-invoke

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mmabrouk

mmabrouk commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

Design-doc-only, no code. Please review the decisions in plan.md / status.md, specifically:

  1. Batch history default (the load-bearing call). Should the batch invoke stay single-message by default and opt into the full turn with flags.history: full (framing 1, zero blast radius), or default to the full outputs.messages and opt into last (framing 2, self-describing for everyone but needs a consumer audit of the product API invoke path / playground FE / evaluators that read messages[-1])? I lean framing 1 — is that right, or do we want batch self-describing by default?
  2. stop_reason placement: outputs.stop_reason (lean, it's run data) vs the workflow status.
  3. Source of the full list: does the streaming-drained run.result() populate .messages/.events (making Option A a handler-local change), or must _agent_batch assemble the list from the events it already iterates? This is the one code fact to verify before implementing.
  4. Option C scope: ship A+B now and track the approval-boundary gap (auto-approved terminal tool's result never reaching the output) as a separate runner-side follow-up — agree?

Live evidence is in context.md/status.md (batch trace 901d24c25f3491fe3badbbb521ea5a55 returned a mid-sentence reply; streaming trace 894862fe8af0c3aae9e63e2637babab9 returned the full 48-event stream for the identical run). The client-side half already shipped in the separate agent-creation-lab kit; this PR is the platform-side design only.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="Agenta-AI/agenta"
pr=5003

echo "=== PR files ==="
gh pr view $pr --repo $repo --json files --jq '.files[].path'

echo "=== PR diff (doc files) ==="
gh pr diff $pr --repo $repo

Length of output: 27341


🏁 Script executed:

#!/bin/bash
set -euo pipefail
git fetch origin big-agents --depth=50 2>&1 | tail -5
git show origin/big-agents:services/oss/src/agent/app.py 2>/dev/null | sed -n '260,325p' || echo "FILE_NOT_FOUND_app.py"
echo "---dtos.py---"
git show origin/big-agents:sdks/python/agenta/sdk/agents/dtos.py 2>/dev/null | sed -n '320,350p;495,520p' || echo "FILE_NOT_FOUND_dtos.py"
echo "---wire.py---"
git show origin/big-agents:sdks/python/agenta/sdk/agents/utils/wire.py 2>/dev/null | sed -n '130,190p' || echo "FILE_NOT_FOUND_wire.py"
echo "---interfaces.py stream/result---"
git show origin/big-agents:sdks/python/agenta/sdk/agents/interfaces.py 2>/dev/null | rg -n 'def stream|def result|class .*Run|messages|events' | head -60

Length of output: 1245


🏁 Script executed:

#!/bin/bash
set -uo pipefail
git fetch origin big-agents --depth=50 2>&1 | tail -3
echo "=== find files ==="
git ls-tree -r --name-only FETCH_HEAD | rg -i 'agent/app\.py$|agents/dtos\.py$|agents/utils/wire\.py$|agents/interfaces\.py$'

Length of output: 639


🏁 Script executed:

#!/bin/bash
set -uo pipefail
echo "=== app.py 260-325 ==="
git show FETCH_HEAD:services/oss/src/agent/app.py | sed -n '260,325p'
echo "=== dtos.py Event & AgentResult ==="
git show FETCH_HEAD:sdks/python/agenta/sdk/agents/dtos.py | sed -n '320,350p;495,525p'
echo "=== wire.py result_from_wire ==="
git show FETCH_HEAD:sdks/python/agenta/sdk/agents/utils/wire.py | sed -n '130,190p'
echo "=== interfaces.py stream/result ==="
git show FETCH_HEAD:sdks/python/agenta/sdk/agents/interfaces.py | rg -n 'def stream|def result|class |messages|events|stop_reason'

Length of output: 8561


🏁 Script executed:

#!/bin/bash
set -uo pipefail
echo "=== search AgentStream class ==="
git show FETCH_HEAD:sdks/python/agenta/sdk/agents/dtos.py | rg -n 'class AgentStream|def result|on_result|on_cleanup' 
echo "=== find AgentStream definition file ==="
git grep -n "class AgentStream" FETCH_HEAD -- 'sdks/**' 2>/dev/null || git show FETCH_HEAD -- sdks | true
git ls-tree -r --name-only FETCH_HEAD | rg -i 'sdks/python/agenta/sdk/agents'

Length of output: 3479


🏁 Script executed:

#!/bin/bash
set -uo pipefail
git show FETCH_HEAD:sdks/python/agenta/sdk/agents/streaming.py | sed -n '1,140p'

Length of output: 4226


🏁 Script executed:

#!/bin/bash
set -uo pipefail
echo "=== interfaces.py stream() context ==="
git show FETCH_HEAD:sdks/python/agenta/sdk/agents/interfaces.py | sed -n '240,296p'
echo "=== search for messages/events keys in runner wire building (rust/ts side) ==="
git ls-tree -r --name-only FETCH_HEAD | rg -i 'runner' | rg -i '\.rs$|\.ts$' | head -20

Length of output: 3296


🏁 Script executed:

#!/bin/bash
set -uo pipefail
git show FETCH_HEAD:services/runner/src/engines/sandbox_agent/transcript.ts | sed -n '1,200p'

Length of output: 3441


🏁 Script executed:

#!/bin/bash
set -uo pipefail
git grep -n "stopReason\|messages:\|events:" FETCH_HEAD -- services/runner/src/engines/sandbox_agent/run-plan.ts services/runner/src/engines/sandbox_agent/daemon.ts services/runner/src/engines/sandbox_agent.ts 2>/dev/null | head -60

Length of output: 1112


🏁 Script executed:

#!/bin/bash
set -uo pipefail
git show FETCH_HEAD:services/runner/src/engines/sandbox_agent.ts | sed -n '580,830p'

Length of output: 11281


🏁 Script executed:

#!/bin/bash
set -uo pipefail
git grep -n "emit" FETCH_HEAD -- services/runner/src/engines/sandbox_agent.ts | head -40

Length of output: 1440


🏁 Script executed:

#!/bin/bash
set -uo pipefail
git grep -n "emit" FETCH_HEAD -- services/runner/src/entry.ts services/runner/src/cli.ts services/runner/src/engines/skills.ts 2>/dev/null | head -30
echo "---"
git grep -rn "runSandboxAgent\|deliver_.*stream\|delivery" FETCH_HEAD -- services/runner/src 2>/dev/null | head -40

Length of output: 3305


🏁 Script executed:

#!/bin/bash
set -uo pipefail
git show FETCH_HEAD:services/runner/src/server.ts | sed -n '100,200p'

Length of output: 4484


🏁 Script executed:

#!/bin/bash
set -uo pipefail
git grep -n "x-ndjson\|application/x-ndjson\|/run\b" FETCH_HEAD -- sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py 2>/dev/null | head -30
echo "---utils dir---"
git ls-tree -r --name-only FETCH_HEAD | rg -i 'sdks/python/agenta/sdk/agents/utils'

Length of output: 1062


🏁 Script executed:

#!/bin/bash
set -uo pipefail
git show FETCH_HEAD:sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py | sed -n '1,170p'

Length of output: 6144


@mmabrouk Reviewed the decisions in plan.md/status.md. Design reasoning is solid, and I traced the code to settle open question 3 (the one you flagged as needing verification). Here's what I found, walking origin/big-agents:

3. Source of the full list — resolved, and it's the pessimistic branch.
result.messages and result.events will not carry the full turn on the streaming-drained result. Traced end to end:

  • _agent_batch and _agent_event_stream both call harness.stream() — confirmed "ONE path: always stream" (services/oss/src/agent/app.py).
  • SandboxAgentSession.stream() always calls self._backend._deliver_stream(...), the NDJSON transport (sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py:120-122) — never the one-shot /run delivery.
  • On the runner, that NDJSON path is exactly runAndStream, which always wires an emit callback (services/runner/src/server.ts), passed into runSandboxAgent.
  • The terminal result builder there is explicit (services/runner/src/engines/sandbox_agent.ts:817-826):
    messages: output ? [{ role: "assistant", content: output }] : [],
    // Streaming already delivered every event live, so the terminal result carries none
    // (re-sending would double them on the consumer).
    events: emit ? [] : run.events(),
    Since emit is always truthy for the transport the SDK uses, events is always [] and messages is always the single synthetic message — for both the batch and stream HTTP shapes, because they share the same harness.stream() call.

So _agent_batch can't lean on result.messages/result.events at all; it has to accumulate the full turn itself from the Events it iterates (currently async for _ in run: pass at app.py:315 discards them). That's a small, mechanical change (append events instead of discarding, then coalesce into the message list on stop_reason/done), but it means Option A is "assemble in the handler," not "read a field that's already there" — worth updating research.md/plan.md wording so the implementer isn't surprised.

On the other three:

1. Batch history default: Agree with framing 1 (default last, opt into full). Given point 3 above, the full-turn assembly is new handler logic either way, so there's no efficiency argument for defaulting to full; keeping zero blast radius while it's unproven in production is the right call. Do the consumer audit before flipping the default later.

2. stop_reason placement: outputs.stop_reason is the better fit — it's a property of the run's output (mirrors what AgentResult.stop_reason already models), not the workflow lifecycle state (status is presumably running/completed/error, a different axis). Keeping them separate avoids overloading status with model-level semantics.

4. Option C scope: Agree, ship A+B now and track C separately. The approval-boundary gap looks like it needs the runner-side HITL/park lifecycle understood first (onCreateInteraction/attachPermissionResponder in sandbox_agent.ts), which is orthogonal to the batch coalescing fix and shouldn't block it.

Overall: plan is sound, the wire-compat framing-1 lean is right, and the one code fact you asked me to verify confirms the harder implementation path — good to flag that explicitly before the implementation PR starts.


✏️ Learnings added
Learnt from: mmabrouk
URL: https://github.com/Agenta-AI/agenta/pull/5003

Timestamp: 2026-07-01T17:18:37.937Z
Learning: In Agenta-AI/agenta's `services/runner` (TS runner, e.g. `services/runner/src/engines/sandbox_agent.ts`), the terminal run result's `messages` field is always a single synthetic `{role:"assistant", content: output}` (never the full turn), and `events` is only populated when `emit` is falsy (i.e., when no live event callback is wired — the one-shot `/run` path). The Python SDK's `SandboxAgentSession.stream()` (`sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py`) always uses the NDJSON streaming delivery (`_deliver_stream`), which always wires `emit` on the runner, so `AgentStream.result()`/`AgentResult.messages`/`.events` obtained via `harness.stream()` never carry the full multi-tool turn — any handler wanting the full turn (e.g. `services/oss/src/agent/app.py`'s `_agent_batch`) must accumulate `Event`s itself while iterating the live stream, not rely on the terminal result's `messages`/`events` fields.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a23edf5e-59e3-4b16-b088-2d398f609586

📥 Commits

Reviewing files that changed from the base of the PR and between 51af4c3 and 572aa89.

📒 Files selected for processing (5)
  • docs/design/agent-workflows/projects/builder-agent-reliability/streaming-invoke/README.md
  • docs/design/agent-workflows/projects/builder-agent-reliability/streaming-invoke/context.md
  • docs/design/agent-workflows/projects/builder-agent-reliability/streaming-invoke/plan.md
  • docs/design/agent-workflows/projects/builder-agent-reliability/streaming-invoke/research.md
  • docs/design/agent-workflows/projects/builder-agent-reliability/streaming-invoke/status.md

Comment on lines +10 to +13
```
I have all the data. Picking `team-testing` (C085TMJJ0PK) as the test channel and posting the
digest now.
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Label the example fence.

This bare fence trips markdownlint (MD040). Mark it as plain text so the example renders cleanly and the doc stays lintable.

♻️ Suggested wording
-```
+```text
 I have all the data. Picking `team-testing` (C085TMJJ0PK) as the test channel and posting the
 digest now.
-```
+```
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
I have all the data. Picking `team-testing` (C085TMJJ0PK) as the test channel and posting the
digest now.
```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 10-10: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

Source: Linters/SAST tools

Comment on lines +18 to +23
- Source of the list: `result.messages` if the streaming-drained terminal result populates it
(research §2 verification item); otherwise assemble it in `_agent_batch` from the events it
already iterates and currently discards (`app.py:315`).
- Also surface `result.stop_reason` on the response (e.g. as `outputs.stop_reason` or a status
field), so a caller can distinguish "ended on a tool turn" from "completed its answer" without
inspecting the message list.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Collapse the already-settled branches.

The review direction here is no longer conditional: _agent_batch must assemble the full turn itself, and stop_reason should be exposed on outputs. Keeping result.messages / workflow status as live options will steer the implementation toward the wrong contract.

Suggested wording
-- Source of the list: `result.messages` if the streaming-drained terminal result populates it
-  (research §2 verification item); otherwise assemble it in `_agent_batch` from the events it
-  already iterates and currently discards (`app.py:315`).
-- Also surface `result.stop_reason` on the response (e.g. as `outputs.stop_reason` or a status
-  field), so a caller can distinguish "ended on a tool turn" from "completed its answer" without
-  inspecting the message list.
++ `_agent_batch` assembles the full turn itself from the drained events it already iterates
++ (`app.py:315`).
++ Surface `result.stop_reason` on `outputs.stop_reason` so callers can distinguish "ended on a
++ tool turn" from "completed its answer" without inspecting the message list.

Also applies to: 71-75

Comment on lines +13 to +16
turn. The full turn — every tool call, every tool result, the stop reason — is already produced by
the runner and is fully visible over a **streaming invoke** (`Accept: application/x-ndjson`),
confirmed live. So the caller *can* already get a reliable, self-describing result today; the batch
default just throws it away.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Tighten the streaming claim.

The README says streaming already exposes every tool result and the stop reason, but the approval-boundary gap documented below means the terminal tool result still does not appear in the stream. Rephrase this as the pre-approval turn so the top-level summary matches the rest of the design.

♻️ Suggested wording
- The full turn — every tool call, every tool result, the stop reason — is already produced by the runner and is fully visible over a **streaming invoke** (`Accept: application/x-ndjson`), confirmed live. So the caller *can* already get a reliable, self-describing result today; the batch default just throws it away.
+ The pre-approval turn — every tool call and tool result before the approval boundary — is already produced by the runner and is fully visible over a **streaming invoke** (`Accept: application/x-ndjson`), confirmed live. The terminal approval-boundary result is tracked separately.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
turn. The full turn — every tool call, every tool result, the stop reason — is already produced by
the runner and is fully visible over a **streaming invoke** (`Accept: application/x-ndjson`),
confirmed live. So the caller *can* already get a reliable, self-describing result today; the batch
default just throws it away.
turn. The pre-approval turn — every tool call and tool result before the approval boundary — is already produced by the runner and is fully visible over a **streaming invoke** (`Accept: application/x-ndjson`), confirmed live. The terminal approval-boundary result is tracked separately.

Comment on lines +41 to +48
`result_from_wire` (`sdks/python/agenta/sdk/agents/utils/wire.py:149-183`) parses the runner's
`/run` result and **populates `messages`, `events`, and `stop_reason`** from the wire
(`data["messages"]`, `data["events"]`, `data["stopReason"]`). So the runner emits them and the SDK
already models them. Verification item: confirm the **streaming-drained** terminal result
(`session.stream(...).result()`, `interfaces.py:278-296`) populates `.messages`/`.events` the same
way the one-shot `/run` result does. If it does, Option A is a handler-local change. If the
streaming result leaves them empty, `_agent_batch` can build the list from the events it already
iterates (it currently discards them at `app.py:315`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

State the resolved finding here.

This still reads like an open verification item, but the PR findings already settle it: the streaming-drained terminal result does not populate .messages/.events the way the one-shot /run result does, so _agent_batch has to assemble the list from the streamed events it already iterates. Update the text to reflect the concluded behavior.

♻️ Suggested wording
- Verification item: confirm the **streaming-drained** terminal result (`session.stream(...).result()`, `interfaces.py:278-296`) populates `.messages`/`.events` the same way the one-shot `/run` result does. If it does, Option A is a handler-local change. If the streaming result leaves them empty, `_agent_batch` can build the list from the events it already iterates (it currently discards them at `app.py:315`).
+ The **streaming-drained** terminal result (`session.stream(...).result()`, `interfaces.py:278-296`) does not populate `.messages`/`.events` the same way the one-shot `/run` result does, so `_agent_batch` must build the list from the events it already iterates.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
`result_from_wire` (`sdks/python/agenta/sdk/agents/utils/wire.py:149-183`) parses the runner's
`/run` result and **populates `messages`, `events`, and `stop_reason`** from the wire
(`data["messages"]`, `data["events"]`, `data["stopReason"]`). So the runner emits them and the SDK
already models them. Verification item: confirm the **streaming-drained** terminal result
(`session.stream(...).result()`, `interfaces.py:278-296`) populates `.messages`/`.events` the same
way the one-shot `/run` result does. If it does, Option A is a handler-local change. If the
streaming result leaves them empty, `_agent_batch` can build the list from the events it already
iterates (it currently discards them at `app.py:315`).
`result_from_wire` (`sdks/python/agenta/sdk/agents/utils/wire.py:149-183`) parses the runner's
`/run` result and **populates `messages`, `events`, and `stop_reason`** from the wire
(`data["messages"]`, `data["events"]`, `data["stopReason"]`). So the runner emits them and the SDK
already models them. The **streaming-drained** terminal result (`session.stream(...).result()`,
`interfaces.py:278-296`) does not populate `.messages`/`.events` the same way the one-shot `/run`
result does, so `_agent_batch` must build the list from the events it already iterates.


The live streaming run exposed a related but distinct gap. The terminal `SEND_MESSAGE` tripped a
`user_approval` interaction. Over a one-shot HTTP invoke there is no reply channel, so the stream
emitted the `interaction_request` and then `done` — it did **not** stream a `SEND_MESSAGE`

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this. I want to determine first if this is a bug. From what I understand from your explanation, if a tool is set to auto-approve and you make a call with streaming, although the tool is called, you don't get the full message. It stops only at the tool call. Now, that is strange behavior, and I wonder if this is a bug or something else.

How does the frontend deal with this? We don't see this issue in the frontend. Does the frontend wait for this user approval interaction and send another message so that it gets the new stream, or how does it work from its side? Why does it happen like that, returning an interaction request instead of finishing? Can you also do a git blame and see when this behavior started happening? From my understanding, the right behavior is that it continues working. If auto-approval happens, it means it happens within the sidecar. We did not need that; that's not a real interaction.

I'd love to add a new page in this PR with more description about this, with much, much more detail: when was this introduced, is it a bug, how the frontends deal with it, etc.

separately close the approval-boundary gap so an auto-approved terminal tool's result is observable
(Option C).

## Option A — batch returns the full `outputs.messages` (recommended)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we are not going to change the Bach Enfoc. It will stay as is.

the single-message shape. Decide against the actual consumers (product API invoke path, playground
FE, evaluators) — see open question 1.

## Option B — streaming is the documented client path (done client-side)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now this is exactly the route that we want to go. From our side, we will not use patch invoke. We will only use streaming. This means in the lab kit we need to use kind of streaming, but also the platform operation needs to use streaming, because otherwise it will have the same problem if that invoke tool is called from within the application.

runs should use a streaming Accept type (or `history: full` once Option A lands), and that the batch
default returns only the final text.

## Option C — close the approval-boundary gap (separate, harder)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a bug. See above comment. Let's first research it before going much more in detail here.

do not depend on it. Until it lands, `check-tools.sh <trace> <WRITE_TOOL>` stays the way to confirm
a gated write returned `ok:true`.

## Decisions needed

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've answered before, so let's go over that before going into details. I don't think any of what you said here is really a real question.

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please read the comments and create a second version of this.
Let's focus on:

  1. The issue with human approvals: is this a bug? What is it?
  2. The main problem, which is how we make the platform tool actually use streaming so that when an agent uses it to test something, it works.

@mmabrouk mmabrouk removed the needs-review Agent updated; awaiting Mahmoud's review label Jul 1, 2026
mmabrouk added a commit that referenced this pull request Jul 1, 2026
… untouched, approval-boundary bug

Second-round revision per PR #5003 review. Batch invoke stays as-is (single
final message). Direction is streaming everywhere the full turn matters: the
client (lab kit, shipped) and the platform's own internal invoke paths
(workflow/agent-as-tool at tools/router.py:1306 and evaluations run batch
today; proposal is a draining invoke_workflow_streaming variant). Adds
approval-boundary.md: an auto-approved run stops at the tool gate because
HITLResponder parks on any session id before consulting the auto policy
(responder.ts:257); introduced in b109cc5; the playground hides it via
useChat resume; verdict is a bug with a fix at responder.ts:254-259.

Claude-Session: https://claude.ai/code/session_01WSp2LqKrEtXnm2fsPWuQWa
@mmabrouk
mmabrouk force-pushed the docs/agent-streaming-invoke branch from 572aa89 to 403e31b Compare July 1, 2026 18:16
@mmabrouk mmabrouk added the needs-review Agent updated; awaiting Mahmoud's review label Jul 1, 2026
@mmabrouk

mmabrouk commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

Second round — revised per your review

Applied your decisions. Batch is untouched; the direction is streaming everywhere; the approval boundary is now a dedicated page with a bug verdict.

1. Batch invoke stays as-is. Dropped the batch-coalescing proposal entirely. _agent_batch keeps returning one final assistant message. No flags.history change, no history-default question. (plan.md, context.md)

2. Streaming everywhere, including the platform's own invoke. The lab kit client already streams. New: where Agenta invokes an agent from inside the platform, it sends Accept: application/json today and hits the same partial-output problem. I traced the platform invoke paths:

  • Workflow/agent-as-toolapi/oss/src/apis/fastapi/tools/router.py:1306invoke_workflow (BATCH), reads only response.data.outputs. This is the real one: an agent invoking another app/agent as a tool gets only the callee's final text.
  • Evaluations runtimeapi/oss/src/core/evaluations/runtime/adapters.py:104,508 (BATCH).
  • Triggers/schedules and session-respond run detached (fire-and-forget) in production via _dispatch_detached_run (api/entrypoints/routers.py:807-811), not batch, so they don't surface the symptom the same way. There is a batch fallback branch, but production streams-detached.
  • No platform path drains the full event stream synchronously. Proposal: a draining invoke_workflow_streaming variant on WorkflowsService (Accept ndjson + drain to the terminal result), convert workflow-as-tool first, evaluations second. (plan.md §"Platform side", research.md §5)

3. Approval boundary — new page approval-boundary.md, verdict: bug. You were right. An auto-approved run stops at the gate because HITLResponder.onPermission returns park whenever a session id is present, before consulting the auto policy (services/agent/src/responder.ts:257), and the SDK mints a sessionId for every invoke (normalizer.py:302-308), so hasHumanSurface is always true and auto is dead code for a gating harness. Park calls destroySession and ends the turn with stopReason: "paused", so the terminal tool's tool_result never streams.

  • The frontend hides it by auto-resending the conversation on the park: useChat({ sendAutomaticallyWhen: agentShouldResumeAfterApproval }) (AgentChatPanel.tsx:259-265), which cold-replays with the {approved} envelope (the same path hardened in the merged PR [fix] Resume HITL Deny end-to-end + guard the dev error overlay #4859). A one-shot HTTP invoke has no resume, so it just stops.
  • Introduced in commit b109cc51ef (2026-06-25) — it changed if (this.hasHumanSurface) return "deny" to return "park". Before 2026-06-24 there was no park and auto-approve continued in-band.
  • Only Claude gates (Pi never does), so the trace is a Claude run; the read-only LIST_* tools were allowlisted, SEND_MESSAGE parked.
  • Fix at responder.ts:254-259: consult the policy before parking (auto → answer allow in-band and keep streaming). Caveat: PermissionPolicy is only auto|deny today, so keeping the playground's approval prompt needs an ask disposition (the relay.ts:108 TODO(S5)).

What feedback I need

  • The platform-side direction: is a draining invoke_workflow_streaming variant the right shape, and is workflow/agent-as-tool the right first conversion? Should triggers/sessions move from detached to inline-drain, or stay detached?
  • The approval-boundary fix: are you OK sequencing the responder.ts:257 one-liner first, then the ask disposition so the playground still prompts? Or do both together?
  • Anything in approval-boundary.md that should go straight to an implementation PR vs stay design here.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -uo pipefail
git fetch origin big-agents --depth=100 2>&1 | tail -3

echo "=== tools/router.py around 1306 ==="
git show FETCH_HEAD:api/oss/src/apis/fastapi/tools/router.py | sed -n '1270,1330p'

echo "=== evaluations adapters.py around 104,508 ==="
git show FETCH_HEAD:api/oss/src/core/evaluations/runtime/adapters.py | sed -n '90,120p;495,520p'

echo "=== entrypoints/routers.py around 790-820 ==="
git show FETCH_HEAD:api/entrypoints/routers.py | sed -n '780,825p'

echo "=== responder.ts around 240-270 ==="
git show FETCH_HEAD:services/agent/src/responder.ts | sed -n '230,275p'

echo "=== normalizer.py around 290-315 ==="
git show FETCH_HEAD:sdks/python/agenta/sdk/agents/utils/normalizer.py | sed -n '285,315p' 2>/dev/null || echo "check file path"
git ls-tree -r --name-only FETCH_HEAD | rg -i 'normalizer\.py$'

echo "=== relay.ts around 100-115 ==="
git ls-tree -r --name-only FETCH_HEAD | rg -i 'relay\.ts$'

Length of output: 7466


🏁 Script executed:

#!/bin/bash
set -uo pipefail

echo "=== find responder.ts ==="
git ls-tree -r --name-only FETCH_HEAD | rg -i 'responder\.ts$'

echo "=== find HITLResponder / onPermission ==="
git grep -n "onPermission\|hasHumanSurface\|class HITLResponder" FETCH_HEAD -- '*.ts' | head -30

echo "=== normalizer.py sessionId ==="
git show FETCH_HEAD:sdks/python/agenta/sdk/middlewares/running/normalizer.py | sed -n '280,320p'

echo "=== relay.ts around 95-120 ==="
git show FETCH_HEAD:services/runner/src/tools/relay.ts | sed -n '90,120p'

echo "=== AgentChatPanel.tsx ==="
git ls-tree -r --name-only FETCH_HEAD | rg -i 'AgentChatPanel\.tsx$'

Length of output: 7209


🏁 Script executed:

#!/bin/bash
set -uo pipefail

echo "=== responder.ts 180-225 ==="
git show FETCH_HEAD:services/runner/src/responder.ts | sed -n '180,225p'

echo "=== resolve_session_id ==="
git grep -n "def resolve_session_id" FETCH_HEAD -- '*.py'
git show FETCH_HEAD:sdks/python/agenta/sdk/middlewares/running/normalizer.py | rg -n "def resolve_session_id" -A 15

echo "=== TODO(S5) in relay.ts ==="
git grep -n "TODO(S5)\|TODO(S5" FETCH_HEAD -- services/runner/src/tools/relay.ts

echo "=== commit b109cc51ef ==="
git log --all --oneline | grep -i b109cc51 || echo "not in shallow history"
git show b109cc51ef --stat 2>&1 | head -30

Length of output: 5524


🏁 Script executed:

#!/bin/bash
set -uo pipefail

echo "=== diff for permissions.ts in b109cc51ef ==="
git show b109cc51ef -- '*permissions.ts' | head -100

echo "=== check if services/agent existed before rename ==="
git log --all --diff-filter=A --name-only -- '*services/agent/src/responder.ts' 2>&1 | head -10
git log --all --oneline -- 'services/runner/src/responder.ts' 2>&1 | tail -5

Length of output: 3897


🏁 Script executed:

#!/bin/bash
set -uo pipefail
git show FETCH_HEAD:sdks/python/agenta/sdk/models/shared.py | sed -n '1,40p'

Length of output: 1601


@mmabrouk Verified the round-2 code claims against origin/big-agents directly — they hold up, with two citation corrections to make in the docs.

Verified independently:

  • api/oss/src/apis/fastapi/tools/router.py — confirmed invoke_workflow(...) is called and only response.data.outputs is read; no streaming/draining anywhere on this path.
  • api/oss/src/core/evaluations/runtime/adapters.py — same invoke_workflow(...) call shape, no Accept override.
  • api/entrypoints/routers.py — confirmed both _interactions_dispatcher and _triggers_dispatcher are wired with dispatch_fn=_dispatch_detached_run, so triggers/schedules and session-respond are fire-and-forget in production, as you said.
  • The approval-boundary bug is real and reproducible from the code, not just the trace. HITLResponder.onPermission in services/runner/src/responder.ts (see the class doc comment at lines ~183-193, and the method body):
    async onPermission(request: PermissionRequest): Promise<ResponderOutcome> {
      const stored = this.lookupPermission(request);
      if (stored) return stored;
      if (this.hasHumanSurface) return "park"; // ← always wins, basePolicy never consulted
      return this.basePolicy === "deny" ? "deny" : "allow";
    }
    basePolicy (including auto) is only reachable in the !hasHumanSurface branch — and since resolve_session_id() in sdks/python/agenta/sdk/models/shared.py mints a uuid4().hex session id whenever one is absent, hasHumanSurface is unconditionally true for every invoke. auto is unreachable dead code for any gating harness. This matches your verdict exactly.
    • Commit b109cc51ef diff confirms the narrative: it replaced an implicit respondPermission(reject) on the ungated branch with "send no reply" on park — that's when the terminal tool_result stopped streaming for parked calls.

Two citation corrections for approval-boundary.md:

  1. The file is now services/runner/src/responder.ts, not services/agent/src/responder.ts — the service was renamed sandbox-agent → runner (commit b323a8516) after b109cc51ef landed, so the pre-rename path is stale.
  2. The session-id minting logic lives in resolve_session_id() in sdks/python/agenta/sdk/models/shared.py, not normalizer.py:302-308 — the normalizer (sdks/python/agenta/sdk/middlewares/running/normalizer.py) just calls that shared function once per request.
  3. Minor: relay.ts's TODO(S5) comments are at lines 150 and 238 currently, not 108 — same text ("surface ask to HITL instead of collapsing onto permissionPolicy"), just line drift.

On your open questions:

  1. Platform-side direction — agree. A draining invoke_workflow_streaming on WorkflowsService is the right shape, and workflow/agent-as-tool is the right first conversion — it's the path where an agent silently gets a truncated view of another agent's work, which compounds (bad sub-result feeds into further tool calls). Evaluations second is right since it's more contained (a single scored run, not chained reasoning). Triggers/session-respond staying detached seems correct for now — there's no synchronous caller waiting on the full turn there, so draining would add latency without an observer to benefit; revisit only if you find a trigger consumer polling spans the way the original batch-invoke caller had to.

  2. Approval-boundary sequencing — agree, ship the one-liner first. Fixing responder.ts to consult basePolicy before defaulting to park when hasHumanSurface is a small, self-contained correctness fix independent of the ask disposition work. The ask-disposition/PermissionPolicy expansion is a larger typed change touching relay.ts's TODO(S5) and the playground prompt flow — sequencing them separately reduces blast radius and lets the small fix ship without waiting on the harder design work.

  3. Design vs implementation PR — the bug diagnosis and the sequencing call are the valuable design output here; I'd send the responder.ts:204 one-liner straight to an implementation PR since it's mechanical once the design doc has the verdict recorded (which it now does). Keep the ask-disposition scoping and the relay.ts TODO(S5) resolution in design a bit longer since it touches the wire-facing PermissionPolicy type and playground UX — worth a short doc addendum on what ask should look like end-to-end before implementing.

Nice diagnosis on the dead-code branch — that's a subtle one to catch from a trace alone.


✏️ Learnings added
Learnt from: mmabrouk
URL: https://github.com/Agenta-AI/agenta/pull/5003

Timestamp: 2026-07-01T18:20:24.898Z
Learning: In Agenta-AI/agenta (`origin/big-agents` branch), `services/runner/src/responder.ts`'s `HITLResponder.onPermission` checks `hasHumanSurface` and returns `"park"` before ever consulting `this.basePolicy` (the `auto`/`deny` policy). Since `resolve_session_id()` in `sdks/python/agenta/sdk/models/shared.py` mints a `uuid4().hex` session id whenever one is absent, `hasHumanSurface` is true for effectively every invoke, making an `auto` `PermissionPolicy` unreachable/dead code for any gating harness (e.g. Claude, which gates permissions; Pi never gates). This was introduced by commit `b109cc51ef` (2026-06-25), which changed the ungated-human-surface branch from an implicit deny-like `respondPermission` reply to sending no reply at all on `park`, so a parked (auto-approved-in-intent) terminal tool's `tool_result` never streams to the client. The service was previously at `services/agent/src/...` before being renamed to `services/runner/src/...` (commit `b323a8516`, `sandbox-agent → runner`), so historical docs/commits referencing `services/agent/src/responder.ts` or `services/agent/src/engines/sandbox_agent/permissions.ts` should be read as the current `services/runner/src/responder.ts` and `services/runner/src/engines/sandbox_agent/permissions.ts`. A tracked `TODO(S5)` in `services/runner/src/tools/relay.ts` (lines ~150, ~238) notes that surfacing an `ask` disposition to HITL (instead of collapsing onto `permissionPolicy`) is still unimplemented.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cad8fa80-8aef-4a27-8a3f-145689adf171

📥 Commits

Reviewing files that changed from the base of the PR and between 572aa89 and 403e31b.

📒 Files selected for processing (6)
  • docs/design/agent-workflows/projects/builder-agent-reliability/streaming-invoke/README.md
  • docs/design/agent-workflows/projects/builder-agent-reliability/streaming-invoke/approval-boundary.md
  • docs/design/agent-workflows/projects/builder-agent-reliability/streaming-invoke/context.md
  • docs/design/agent-workflows/projects/builder-agent-reliability/streaming-invoke/plan.md
  • docs/design/agent-workflows/projects/builder-agent-reliability/streaming-invoke/research.md
  • docs/design/agent-workflows/projects/builder-agent-reliability/streaming-invoke/status.md
✅ Files skipped from review due to trivial changes (1)
  • docs/design/agent-workflows/projects/builder-agent-reliability/streaming-invoke/status.md

Comment on lines +35 to +67
(`services/agent/src/engines/sandbox_agent/permissions.ts:63-77`). This is the only emitter of
`kind: user_approval` in the codebase, so the event in the trace fires for every gate the harness
raises, regardless of policy.

2. **The responder parks on a "human surface" before it checks the policy.**
`HITLResponder.onPermission` (`services/agent/src/responder.ts:254-259`):

```ts
async onPermission(request: PermissionRequest): Promise<ResponderOutcome> {
const stored = this.lookupPermission(request);
if (stored) return stored;
if (this.hasHumanSurface) return "park"; // line 257 — parks REGARDLESS of policy
return this.basePolicy === "deny" ? "deny" : "allow"; // headless-only, unreachable in practice
}
```

Line 257 returns `park` whenever `hasHumanSurface` is true, *before* it ever looks at
`basePolicy`. The auto-allow branch on line 258 is only reachable when there is no human surface.
`PermissionPolicy` is only `"auto" | "deny"` (`responder.ts:33`) — there is no `ask` value — so the
responder cannot tell "this specific tool needs a human" from "the policy auto-approves
everything." It parks on all of them.

3. **`hasHumanSurface` is just "is there a sessionId".**
`services/agent/src/engines/sandbox_agent.ts:511`:
`const hasHumanSurface = !!(request.sessionId && request.sessionId.trim());`
The responder is built with that flag (`sandbox_agent.ts:537-544`).

4. **The SDK mints a sessionId for every invoke, headless or not.** The running-middleware normalizer
resolves a session id once before the handler runs and mints one when it is absent
(`sdks/python/agenta/sdk/middlewares/running/normalizer.py:302-308`,
`sdks/python/agenta/sdk/models/shared.py:13-22` — `resolve_session_id` returns `uuid4().hex`). It
flows into `SessionConfig.session_id` (`services/oss/src/agent/app.py:216, 254`) and becomes the
runner's `AgentRunRequest.sessionId`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Refresh the stale code-path citations.

These references still point at services/agent/src/..., but the current runner-side implementation is under services/runner/src/responder.ts, and the relay.ts TODO line has moved. Since this page is meant to pin exact locations, please update the root-cause section and the key-files list together.

Also applies to: 103-107, 218-224

🧰 Tools
🪛 LanguageTool

[grammar] ~62-~62: Ensure spelling is correct
Context: ...ent.ts:537-544`). 4. The SDK mints a sessionId for every invoke, headless or not. Th...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

Comment on lines +41 to +64
Streaming is not a workaround bolted on for this test. It is a first-class negotiated format the SDK
routing already supports (`Accept: text/event-stream | application/x-ndjson | application/jsonl`, or
`flags.stream: true`). All three return 200 with the full event stream.

## The direction: stream everywhere, leave batch alone

Batch returns only the final assistant text by design, and it stays that way. It is the right shape for
a caller that wants one final message. We are **not** reshaping batch or coalescing the full turn into
its response.

The answer to "the multi-tool output is unreliable" is to use **streaming** on every path that needs
the whole turn. There are two such paths:

1. **The external client.** A client asks for a stream (`Accept: application/x-ndjson`) and reads the
full turn directly. This is already done in the lab kit (`test-agent.sh` streams and prints the
ordered tool list; see `plan.md`).
2. **The platform's own invoke.** When Agenta invokes an agent from *inside* the platform — an agent
invoking another app/agent as a tool, an evaluation running a workflow-under-test, a trigger firing
a workflow — the same batch-vs-stream choice applies, set by the `Accept` header the platform sends.
Today those paths send `Accept: application/json` and read a single batch body, so they hit the
identical empty/mid-sentence problem, now server-side. The platform-side change is to make the
result-consuming invoke paths stream and drain the event stream. Where the platform invokes agents,
which paths are batch today, and the proposed change are in `plan.md` and `research.md`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clarify the stream-negotiation rule.

The later 406 example shows that flags.stream: true is not a standalone override when Accept is application/json. As written, “or flags.stream: true” / “All three return 200” reads too broadly and can mislead anyone implementing the platform-side drain.

Suggested wording
- Streaming is a first-class negotiated format the SDK routing already supports (`Accept: text/event-stream | application/x-ndjson | application/jsonl`, or `flags.stream: true`). All three return 200 with the full event stream.
+ Streaming is a first-class negotiated format the SDK routing already supports (`Accept: text/event-stream | application/x-ndjson | application/jsonl`); `flags.stream: true` still requires a stream-compatible `Accept` header.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Streaming is not a workaround bolted on for this test. It is a first-class negotiated format the SDK
routing already supports (`Accept: text/event-stream | application/x-ndjson | application/jsonl`, or
`flags.stream: true`). All three return 200 with the full event stream.
## The direction: stream everywhere, leave batch alone
Batch returns only the final assistant text by design, and it stays that way. It is the right shape for
a caller that wants one final message. We are **not** reshaping batch or coalescing the full turn into
its response.
The answer to "the multi-tool output is unreliable" is to use **streaming** on every path that needs
the whole turn. There are two such paths:
1. **The external client.** A client asks for a stream (`Accept: application/x-ndjson`) and reads the
full turn directly. This is already done in the lab kit (`test-agent.sh` streams and prints the
ordered tool list; see `plan.md`).
2. **The platform's own invoke.** When Agenta invokes an agent from *inside* the platform — an agent
invoking another app/agent as a tool, an evaluation running a workflow-under-test, a trigger firing
a workflow — the same batch-vs-stream choice applies, set by the `Accept` header the platform sends.
Today those paths send `Accept: application/json` and read a single batch body, so they hit the
identical empty/mid-sentence problem, now server-side. The platform-side change is to make the
result-consuming invoke paths stream and drain the event stream. Where the platform invokes agents,
which paths are batch today, and the proposed change are in `plan.md` and `research.md`.
Streaming is not a workaround bolted on for this test. It is a first-class negotiated format the SDK
routing already supports (`Accept: text/event-stream | application/x-ndjson | application/jsonl`);
`flags.stream: true` still requires a stream-compatible `Accept` header. All three return 200 with the
full event stream.
## The direction: stream everywhere, leave batch alone
Batch returns only the final assistant text by design, and it stays that way. It is the right shape for
a caller that wants one final message. We are **not** reshaping batch or coalescing the full turn into
its response.
The answer to "the multi-tool output is unreliable" is to use **streaming** on every path that needs
the whole turn. There are two such paths:
1. **The external client.** A client asks for a stream (`Accept: application/x-ndjson`) and reads the
full turn directly. This is already done in the lab kit (`test-agent.sh` streams and prints the
ordered tool list; see `plan.md`).
2. **The platform's own invoke.** When Agenta invokes an agent from *inside* the platform — an agent
invoking another app/agent as a tool, an evaluation running a workflow-under-test, a trigger firing
a workflow — the same batch-vs-stream choice applies, set by the `Accept` header the platform sends.
Today those paths send `Accept: application/json` and read a single batch body, so they hit the
identical empty/mid-sentence problem, now server-side. The platform-side change is to make the
result-consuming invoke paths stream and drain the event stream. Where the platform invokes agents,
which paths are batch today, and the proposed change are in `plan.md` and `research.md`.

Comment on lines +23 to +28
- **The platform's own invoke must stream too.** When Agenta invokes an agent from inside the
platform — an agent invoking another app/agent as a tool, an evaluation running a
workflow-under-test, a trigger firing a workflow — it sends `Accept: application/json` today and
gets the same partial output. The proposed change is a draining `invoke_workflow_streaming` variant
on `WorkflowsService`, applied first to the workflow/agent-as-tool path
(`api/oss/src/apis/fastapi/tools/router.py:1306`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Narrow the internal-invoke scope.

plan.md only treats workflow/agent-as-tool and evaluations as synchronous result consumers. Triggers/schedules and session-respond are detached in production, so this sentence currently overstates which paths see the partial-output failure.

Suggested wording
- When Agenta invokes an agent from inside the platform — an agent invoking another app/agent as a tool, an evaluation running a workflow-under-test, a trigger firing a workflow — it sends Accept: application/json today and gets the same partial output.
+ When Agenta invokes an agent from inside the platform — especially workflow/agent-as-tool and evaluations, which consume the result synchronously — it sends `Accept: application/json` today and gets the same partial output.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- **The platform's own invoke must stream too.** When Agenta invokes an agent from inside the
platform — an agent invoking another app/agent as a tool, an evaluation running a
workflow-under-test, a trigger firing a workflow — it sends `Accept: application/json` today and
gets the same partial output. The proposed change is a draining `invoke_workflow_streaming` variant
on `WorkflowsService`, applied first to the workflow/agent-as-tool path
(`api/oss/src/apis/fastapi/tools/router.py:1306`).
When Agenta invokes an agent from inside the platform — especially workflow/agent-as-tool and evaluations, which consume the result synchronously — it sends `Accept: application/json` today and gets the same partial output.

@mmabrouk

mmabrouk commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

Rebased onto big-agents f8765a9b89 (current base, post services/agent -> services/runner rename). Content unchanged from the prior review round; diff is exactly this project's files. Ready for review.

@jp-agenta

Copy link
Copy Markdown
Member

This seems ton impact /invoke. Let's (a)sync. @mmabrouk

@mmabrouk mmabrouk closed this Jul 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-review Agent updated; awaiting Mahmoud's review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants