Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions aperag/domains/agent_runtime/tools/consent.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
(``state="expired"``).
4. On approval, the runtime fetches raw args via
:meth:`ConsentService.consume_raw_args` and dispatches the tool.
On denial / expiry, the runtime emits
``tool-output-available {errorText}`` to surface the rejection.
On denial / expiry, the runtime emits a ``tool-output-error``
wire part (per AI SDK v5 strict spec) to surface the rejection.

Design points:

Expand Down
6 changes: 3 additions & 3 deletions aperag/domains/agent_runtime/tools/elicitation.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@
State transitions: ``pending -> submitted | cancelled``. There is no
"expired" state for elicitation in D9 §5.1 -- the runtime decides
whether to time out the awaiting tool execution and surface a
``tool-output-available {errorText}`` instead. We mirror that here
by exposing :meth:`ElicitationService.cancel` for the runtime to
call on abort / timeout.
``tool-output-error`` wire part (per AI SDK v5 strict spec) instead.
We mirror that here by exposing :meth:`ElicitationService.cancel` for
the runtime to call on abort / timeout.
"""

from __future__ import annotations
Expand Down
45 changes: 40 additions & 5 deletions aperag/domains/agent_runtime/wire/parts.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
/ ``abort`` / ``error``
* text: ``text-start`` / ``text-delta`` / ``text-end``
* tool lifecycle: ``tool-input-start`` / ``tool-input-delta`` /
``tool-input-available`` / ``tool-output-available``
``tool-input-available`` / ``tool-output-available`` /
``tool-output-error``
* sources: ``source-url`` / ``source-document``
* ApeRAG custom: ``data-citation`` / ``data-activity``
* placeholders for #75 chenyexuan: ``data-tool-consent`` /
Expand Down Expand Up @@ -178,15 +179,47 @@ class ToolInputAvailablePart(BaseModel):


class ToolOutputAvailablePart(BaseModel):
"""Tool finished. ``error_text`` set ⇒ tool failure (output may
still carry a partial error payload depending on the tool)."""
"""Tool finished successfully — carries the tool's ``output`` payload.

Per AI SDK v5 strict spec, success and failure are split onto two
distinct wire events: ``tool-output-available`` (this class) for the
success path and :class:`ToolOutputErrorPart` for the failure path.
A failed tool call must NOT be emitted as ``tool-output-available``
with a populated ``errorText``; the FE reducer collapses both events
into the consolidated at-rest :class:`ToolPart` state machine
(``state ∈ output-available | output-error``).

``extra="forbid"`` actively rejects the legacy
``ToolOutputAvailablePart(errorText=...)`` shape so that residual
callers from before the D8.0c+ #89 split surface as a clean
``ValidationError`` instead of silently masquerading as success.
"""

type: Literal["tool-output-available"] = "tool-output-available"
tool_call_id: str = Field(alias="toolCallId")
output: Any = None
error_text: Optional[str] = Field(default=None, alias="errorText")

model_config = ConfigDict(populate_by_name=True)
model_config = ConfigDict(populate_by_name=True, extra="forbid")


class ToolOutputErrorPart(BaseModel):
"""Tool finished with a failure — carries the ``error_text``.

Per AI SDK v5 strict spec the error path is a separate wire event
type; ``error_text`` is required (the FE always has something to
render) and there is no ``output`` field — partial failure outputs
are flattened into the textual error_text by the runtime emitter.

``extra="forbid"`` mirrors the success-path discipline so an
accidental ``output=...`` kwarg here surfaces as a
``ValidationError`` rather than silently flowing through.
"""

type: Literal["tool-output-error"] = "tool-output-error"
tool_call_id: str = Field(alias="toolCallId")
error_text: str = Field(alias="errorText")

model_config = ConfigDict(populate_by_name=True, extra="forbid")


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -359,6 +392,7 @@ class DataElicitationPart(BaseModel):
ToolInputDeltaPart,
ToolInputAvailablePart,
ToolOutputAvailablePart,
ToolOutputErrorPart,
SourceUrlPart,
SourceDocumentPart,
DataCitationPart,
Expand Down Expand Up @@ -428,6 +462,7 @@ def parse_part(payload: dict[str, Any]) -> StreamPart:
"ToolInputDeltaPart",
"ToolInputStartPart",
"ToolOutputAvailablePart",
"ToolOutputErrorPart",
"dump_part",
"parse_part",
]
21 changes: 14 additions & 7 deletions aperag/domains/agent_runtime/wire/translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
ToolInputAvailablePart,
ToolInputStartPart,
ToolOutputAvailablePart,
ToolOutputErrorPart,
)

SafeToolNameResolver = Callable[[str], tuple[str, dict[str, Any]]]
Expand Down Expand Up @@ -300,23 +301,29 @@ def _translate_tool_started(envelope: AgentTimelineEventEnvelope, state: Transla


def _translate_tool_finished(envelope: AgentTimelineEventEnvelope) -> list[StreamPart]:
output = _extract_tool_output(envelope)
error_text: Optional[str] = None
if _is_failure_status(envelope.status):
# Best-effort error text — fall back to the envelope label so
# the FE always has something to render even when the tool
# didn't surface an explicit error message.
# Per AI SDK v5 strict spec the failure path emits a separate
# ``tool-output-error`` event, not ``tool-output-available`` with
# an embedded ``errorText``. Best-effort error text — fall back
# to the envelope label so the FE always has something to
# render even when the tool didn't surface an explicit error
# message.
data = envelope.data or {}
error_text = (
str(data.get("error"))
if data.get("error")
else f"tool {envelope.label or 'unknown'} failed with status={envelope.status}"
)
return [
ToolOutputErrorPart(
tool_call_id=envelope.event_id,
error_text=error_text,
)
]
return [
ToolOutputAvailablePart(
tool_call_id=envelope.event_id,
output=output,
error_text=error_text,
output=_extract_tool_output(envelope),
)
]

Expand Down
2 changes: 1 addition & 1 deletion docs/modularization/agent-message-protocol-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Stream parts (AI SDK v5):
- Lifecycle: `start` / `start-step` / `finish-step` / `finish` / `abort` / `error`
- Text: `text-start {id}` → `text-delta {id, delta}` → `text-end {id}`
- Reasoning: `reasoning-start/-delta/-end`
- Tools: `tool-input-start {toolCallId, toolName}` → `tool-input-delta` → `tool-input-available` → `tool-output-available`
- Tools: `tool-input-start {toolCallId, toolName}` → `tool-input-delta` → `tool-input-available` → `tool-output-available` *(success)* | `tool-output-error {errorText}` *(failure, AI SDK v5 strict spec — D8.0c+ #89)*
- Sources: `source-url {sourceId, url, title?}` / `source-document {sourceId, mediaType, title}`
- Custom (ApeRAG 扩展):
- `data-citation {cited_text, location: char_location | page_location | content_block_location | url_citation}`(Anthropic-shape)
Expand Down
4 changes: 2 additions & 2 deletions docs/modularization/agent-runtime-mcp-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Registry 必须存 `(mcpServer, mcpToolName, safeName)` 三元组以保证 safeN
D8.3 tool lifecycle implementation 必须 enforce:
- **Visibility filter**: 不 visible 的 tool 不进入 agent 的 system prompt tool list
- **Consent gate hook**: agent 决定调用 consent-required tool 时,不直接发 `tool-input-start`,而是先发 `data-tool-consent` part,等待用户 decision;approved 后发 `tool-input-start` continue
- **Invocation block**: agent 调用 blocked tool 时直接返回 `tool-output-available` with `errorText: "Tool invocation denied: requires user consent"`
- **Invocation block**: agent 调用 blocked tool 时直接发 `tool-output-error` (AI SDK v5 strict spec, post D8.0c+ #89) with `errorText: "Tool invocation denied: requires user consent"`

## 3. Tool Consent UI in UIMessage Parts

Expand Down Expand Up @@ -137,7 +137,7 @@ Frontend 渲染 consent UI 用 `toolName + argsPreview + risk` 给用户决策
5. backend updates data-tool-consent part state → "approved" or "denied"
6. backend → agent runtime:
- approved: continue with tool-input-start lifecycle (raw args used internally)
- denied: emit tool-output-available with errorText "denied by user"
- denied: emit tool-output-error with errorText "denied by user" (AI SDK v5 strict spec, post D8.0c+ #89)
```

### 3.3 Timeout policy
Expand Down
52 changes: 49 additions & 3 deletions tests/unit_test/test_agent_runtime_wire_parts.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
ToolInputAvailablePart,
ToolInputStartPart,
ToolOutputAvailablePart,
ToolOutputErrorPart,
)


Expand Down Expand Up @@ -205,7 +206,8 @@ def test_translate_tool_started_finished():
assert isinstance(finished[0], ToolOutputAvailablePart)
assert finished[0].tool_call_id == "event-tool"
assert finished[0].output == {"items": [{"id": "doc-1"}]}
assert finished[0].error_text is None
# Strict AI SDK v5: success path has no error_text field at all.
assert not hasattr(finished[0], "error_text")


def test_translate_tool_failure():
Expand All @@ -225,7 +227,9 @@ def test_translate_tool_failure():

assert len(parts) == 1
out = parts[0]
assert isinstance(out, ToolOutputAvailablePart)
# Strict AI SDK v5: failures emit ``tool-output-error``, NOT
# ``tool-output-available`` with errorText (D8.0c+ #89 fix-forward).
assert isinstance(out, ToolOutputErrorPart)
assert out.tool_call_id == "event-fail"
assert out.error_text == "rate limited"

Expand Down Expand Up @@ -407,7 +411,8 @@ def test_part_json_round_trip():
metadata={"mcpServer": "aperag", "mcpToolName": "search_collection"},
),
ToolInputAvailablePart(tool_call_id="t-1", tool_name="search_collection", input={"q": "rag"}),
ToolOutputAvailablePart(tool_call_id="t-1", output={"items": []}, error_text=None),
ToolOutputAvailablePart(tool_call_id="t-1", output={"items": []}),
ToolOutputErrorPart(tool_call_id="t-2", error_text="rate limited"),
SourceUrlPart(source_id="src-1", url="https://example.com/rag", title="RAG Primer"),
DataCitationPart(
data={
Expand Down Expand Up @@ -447,6 +452,47 @@ def test_part_wire_uses_camel_case_keys():
assert "error_text" not in error


def test_tool_output_strict_split_per_ai_sdk_v5():
"""Strict AI SDK v5: tool failure emits ``tool-output-error``,
success emits ``tool-output-available``. Neither part type carries
the other's payload (D8.0c+ #89 fix-forward)."""

success_dict = dump_part(ToolOutputAvailablePart(tool_call_id="t-ok", output={"items": []}))
assert success_dict["type"] == "tool-output-available"
# Success path MUST NOT carry an errorText key on the wire.
assert "errorText" not in success_dict
assert "error_text" not in success_dict

error_dict = dump_part(ToolOutputErrorPart(tool_call_id="t-fail", error_text="rate limited"))
assert error_dict["type"] == "tool-output-error"
assert error_dict["errorText"] == "rate limited"
assert error_dict["toolCallId"] == "t-fail"
# Failure path MUST NOT carry an output key on the wire.
assert "output" not in error_dict

# Both shapes are recognized by the discriminated union TypeAdapter.
assert isinstance(parse_part(success_dict), ToolOutputAvailablePart)
assert isinstance(parse_part(error_dict), ToolOutputErrorPart)


def test_tool_output_available_rejects_error_text_kwarg():
"""ToolOutputAvailablePart no longer accepts ``error_text`` —
constructing it with one must surface as a Pydantic error rather
than silently coexist (regression guard for #89 strict split)."""

import pydantic

with pytest.raises(pydantic.ValidationError):
ToolOutputAvailablePart.model_validate(
{
"type": "tool-output-available",
"toolCallId": "t-1",
"output": {"items": []},
"errorText": "should not be accepted",
}
)


# ---------------------------------------------------------------------------
# SSE route contract tests
# ---------------------------------------------------------------------------
Expand Down
17 changes: 6 additions & 11 deletions web/src/features/agent-runtime/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,25 +139,20 @@ export function applyPart(
}),
lastSequence: maxSeq(state.lastSequence, eventId),
};
case 'tool-output-available': {
// BE today (#73) emits failures here too with `errorText` set;
// task #89 splits failures onto `tool-output-error`. We accept
// both shapes so the FE rolls forward without coupling to BE
// timing.
const failed =
typeof part.errorText === 'string' && part.errorText.length > 0;
case 'tool-output-available':
// Strict AI SDK v5 success shape (post task #89 fix-forward):
// success path carries only `output`. Failures arrive as a
// separate `tool-output-error` event handled below.
return {
...state,
parts: upsertTool(state.parts, part.toolCallId, {
output: part.output,
errorText: failed ? part.errorText! : undefined,
state: failed ? 'output-error' : 'output-available',
state: 'output-available',
}),
lastSequence: maxSeq(state.lastSequence, eventId),
};
}
case 'tool-output-error':
// Strict AI SDK v5 failure shape (task #89 fix-forward target).
// Strict AI SDK v5 failure shape (task #89 fix-forward landed).
return {
...state,
parts: upsertTool(state.parts, part.toolCallId, {
Expand Down
6 changes: 0 additions & 6 deletions web/src/features/agent-runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,6 @@ export type StreamPart =
type: 'tool-output-available';
toolCallId: string;
output: unknown;
// BE `parts.py` (#73) currently sets `errorText` here on failure;
// AI SDK v5 strict spec migrates failure to `tool-output-error`
// (see task #89 D8.0c+ hygiene fix-forward). The reducer accepts
// both shapes so the FE rolls forward without coupling to the
// BE's split timing.
errorText?: string | null;
}
| { type: 'tool-output-error'; toolCallId: string; errorText: string }
| { type: 'source-url'; sourceId: string; url: string; title?: string | null }
Expand Down
Loading