From dba2de590ba5ed4cfc4dbd24f6d721d2bc347f9d Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:08:50 +0100 Subject: [PATCH] Show the arguments a tool call sends, fixed values included The approval card and a finished call showed what the model asked for, even where a fixed value replaced it. Calls now carry sent_arguments for the chat, leaving headers out since they may hold a fixed secret. The model's own arguments are kept as they were, because they are what later turns replay to it and it never sees fixed values. --- docsgpt/agents/tool_executor.py | 13 +++++- docsgpt/agents/tool_pins.py | 26 +++++++++++ docsgpt/llm/handlers/base.py | 3 ++ frontend/src/conversation/AnswerFlow.tsx | 6 +-- .../conversation/ConversationBubble.test.tsx | 24 ++++++++++ .../src/conversation/ConversationBubble.tsx | 6 +-- frontend/src/conversation/types/index.ts | 7 +++ tests/agents/test_tool_pins.py | 45 +++++++++++++++++++ tests/test_tool_approval.py | 24 ++++++++++ 9 files changed, 147 insertions(+), 7 deletions(-) diff --git a/docsgpt/agents/tool_executor.py b/docsgpt/agents/tool_executor.py index 4db23d1e..95f8ba6b 100644 --- a/docsgpt/agents/tool_executor.py +++ b/docsgpt/agents/tool_executor.py @@ -14,7 +14,7 @@ from docsgpt.agents.default_tools import ( synthesized_default_tools, ) from docsgpt import tracing -from docsgpt.agents.tool_pins import iter_parameters, llm_fills, resolve_arguments +from docsgpt.agents.tool_pins import iter_parameters, llm_fills, resolve_arguments, sent_arguments, shown_arguments from docsgpt.agents.tools.tool_action_parser import ToolActionParser from docsgpt.agents.tools.tool_manager import ToolManager from docsgpt.guardrails.types import Stage as GuardrailStage, resolve_tool_result @@ -1102,6 +1102,12 @@ class ToolExecutor: "pause_type": "awaiting_approval", "thought_signature": getattr(call, "thought_signature", None), } + # The card shows what will be sent: fixed values replace what the + # model asked for. ``arguments`` stays as the model sent it, since + # resuming replays it to the model, which never sees fixed values. + sent = sent_arguments(action_data, arguments, self._connection_parameters(tool_data)) + if action_data and sent != arguments: + payload["sent_arguments"] = sent # Surface the device id so the approval UI can offer a # "don't ask again" sticky-pattern action for remote devices. if tool_data.get("name") == "remote_device": @@ -1482,6 +1488,11 @@ class ToolExecutor: sections = resolve_arguments(action_data, call_args, self._connection_parameters(tool_data)) query_params, headers = sections["query_params"], sections["headers"] body, parameters = sections["body"], sections["parameters"] + # The chat shows what was sent; ``arguments`` keeps what the model + # asked for, which is what a later turn replays to it. + sent = shown_arguments(sections) + if sent != (call_args if isinstance(call_args, dict) else {}): + tool_call_data["sent_arguments"] = sent # Load tool (with caching) from docsgpt.connectors.service import ConnectionUnavailable diff --git a/docsgpt/agents/tool_pins.py b/docsgpt/agents/tool_pins.py index f473f175..51efb800 100644 --- a/docsgpt/agents/tool_pins.py +++ b/docsgpt/agents/tool_pins.py @@ -104,6 +104,32 @@ def resolve_arguments( return resolved +def shown_arguments(sections: Mapping[str, Mapping[str, Any]]) -> dict: + """One flat ``{parameter: value}`` view of resolved sections, for the chat. + + Headers are left out: they may carry a pinned secret (an API key the + owner fixed), and the chat is shown to whoever runs the agent. + """ + shown: dict = {} + for section in PARAM_SECTIONS: + if section != "headers": + shown.update(sections.get(section) or {}) + return shown + + +def sent_arguments( + action: Mapping, + llm_arguments: Mapping[str, Any], + connection_pins: Optional[Mapping[str, Any]] = None, +) -> dict: + """What a call sends, flattened for display (see :func:`shown_arguments`). + + The approval card and the finished call show this rather than what the + model asked for, which fixed values may have replaced. + """ + return shown_arguments(resolve_arguments(action, llm_arguments, connection_pins)) + + def coerce_value(details: Mapping, value: Any) -> Any: """Check a value for a pin against the parameter's declared type. diff --git a/docsgpt/llm/handlers/base.py b/docsgpt/llm/handlers/base.py index a8090056..1d432f81 100644 --- a/docsgpt/llm/handlers/base.py +++ b/docsgpt/llm/handlers/base.py @@ -1240,6 +1240,9 @@ class LLMHandler(ABC): # can wire the sticky "don't ask again" button. if pause_info.get("device_id"): pause_data["device_id"] = pause_info["device_id"] + # What will be sent once fixed values replace the model's. + if pause_info.get("sent_arguments") is not None: + pause_data["sent_arguments"] = pause_info["sent_arguments"] # A connection-backed tool whose account needs signing in: the # approval card becomes a Connect card. if pause_info.get("connection_required"): diff --git a/frontend/src/conversation/AnswerFlow.tsx b/frontend/src/conversation/AnswerFlow.tsx index 6d04f089..7e208297 100644 --- a/frontend/src/conversation/AnswerFlow.tsx +++ b/frontend/src/conversation/AnswerFlow.tsx @@ -17,7 +17,7 @@ import { AnswerSegment, getAnswerSegments } from './answerSegments'; import MarkdownAnswer from './MarkdownAnswer'; import { type SandboxArtifact } from './sandboxLinks'; import StreamingStatusLine from './StreamingStatusLine'; -import { ToolCallsType } from './types'; +import { shownArguments, ToolCallsType } from './types'; import { isWikiWriteCall } from './wikiToolCall'; import { cn } from '@/lib/utils'; @@ -310,10 +310,10 @@ function InlineToolCallChip({
- {JSON.stringify(toolCall.arguments ?? {}, null, 2)} + {JSON.stringify(shownArguments(toolCall), null, 2)}
- {JSON.stringify(toolCall.arguments, null, 2)}
+ {JSON.stringify(shownArguments(toolCall), null, 2)}