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)}
{
(b) => b.textContent?.trim() === text,
) as HTMLButtonElement;
+ it('shows the fixed values a call will send, not what the model asked', async () => {
+ await render(
+ {}}
+ />,
+ );
+ expect(container.textContent).toContain('"chat_id":"111"');
+ expect(container.textContent).not.toContain('666');
+ const details = container.querySelector(
+ `button[aria-label="${tr('conversation.toolApproval.details')}"]`,
+ ) as HTMLButtonElement;
+ await act(async () => details.click());
+ expect(container.querySelector('pre')?.textContent).toContain('"111"');
+ expect(container.textContent).not.toContain('666');
+ });
+
it('renders the approval bar on Button variants and disables Approve while a deny reason is typed', async () => {
await render(
60 ? argPreview.slice(0, 57) + '...' : argPreview;
@@ -899,7 +899,7 @@ function ToolCallApprovalBar({
- {JSON.stringify(toolCall.arguments, null, 2)}
+ {JSON.stringify(shownArguments(toolCall), null, 2)}
diff --git a/frontend/src/conversation/types/index.ts b/frontend/src/conversation/types/index.ts
index 4e925c84..e119bf9c 100644
--- a/frontend/src/conversation/types/index.ts
+++ b/frontend/src/conversation/types/index.ts
@@ -3,6 +3,9 @@ export type ToolCallsType = {
action_name: string;
call_id: string;
arguments: Record;
+ // What the call sends once fixed values replace what the model asked for;
+ // absent when they are the same. The chat shows this.
+ sent_arguments?: Record;
result?: Record;
error?: string;
status?:
@@ -39,3 +42,7 @@ export type ToolCallsType = {
connector_name?: string | null;
access?: 'read' | 'write' | null;
};
+
+/** The arguments to show for a call: what it sends, not what the model asked. */
+export const shownArguments = (toolCall: ToolCallsType): Record =>
+ toolCall.sent_arguments ?? toolCall.arguments ?? {};
diff --git a/tests/agents/test_tool_pins.py b/tests/agents/test_tool_pins.py
index 383262da..a3366099 100644
--- a/tests/agents/test_tool_pins.py
+++ b/tests/agents/test_tool_pins.py
@@ -238,3 +238,48 @@ class TestPinHelpers:
carried = carry_pins(old, fresh)
assert carried["parameters"]["properties"]["team"]["value"] == "ENG"
assert carried["parameters"]["properties"]["team"]["filled_by_llm"] is False
+
+
+@pytest.mark.unit
+class TestArgumentsShownForACall:
+ """What the chat shows for a call is what is sent, not what the model asked."""
+
+ def _pause(self, executor, action, call_args, monkeypatch):
+ monkeypatch.setattr(
+ "docsgpt.agents.tool_executor.ToolActionParser",
+ lambda _cls, **kw: Mock(parse_args=Mock(return_value=("t1", "telegram_send_message", call_args))),
+ )
+ call = Mock()
+ call.name = "telegram_send_message"
+ call.id = "c1"
+ return executor.check_pause(_tools(action), call, "MockLLM")
+
+ def test_approval_card_shows_the_fixed_value(self, monkeypatch):
+ action = {**_action(text=_llm(), chat_id=_pinned("111")), "require_approval": True}
+ pending = self._pause(ToolExecutor(user="u"), action, {"text": "hi", "chat_id": "666"}, monkeypatch)
+ assert pending["pause_type"] == "awaiting_approval"
+ assert pending["sent_arguments"] == {"text": "hi", "chat_id": "111"}
+ # What the model asked stays as it was: resuming replays it to the model.
+ assert pending["arguments"] == {"text": "hi", "chat_id": "666"}
+
+ def test_no_separate_arguments_when_nothing_changes(self, monkeypatch):
+ action = {**_action(text=_llm()), "require_approval": True}
+ pending = self._pause(ToolExecutor(user="u"), action, {"text": "hi"}, monkeypatch)
+ assert "sent_arguments" not in pending
+
+ def test_a_finished_call_records_what_was_sent(self, mock_tool_manager, monkeypatch):
+ executor = ToolExecutor(user="u")
+ action = _action(text=_llm(), chat_id=_pinned("111"))
+ _run(executor, _tools(action), {"text": "hi", "chat_id": "666"}, monkeypatch)
+ recorded = executor.tool_calls[-1]
+ assert recorded["sent_arguments"] == {"text": "hi", "chat_id": "111"}
+ assert recorded["arguments"] == {"text": "hi", "chat_id": "666"}
+
+ def test_headers_are_never_shown(self):
+ from docsgpt.agents.tool_pins import sent_arguments
+
+ action = {
+ "headers": {"properties": {"Authorization": _pinned("Bearer secret")}},
+ "query_params": {"properties": {"id": _llm()}},
+ }
+ assert sent_arguments(action, {"id": "1"}) == {"id": "1"}
diff --git a/tests/test_tool_approval.py b/tests/test_tool_approval.py
index ee1f99b0..de9e2d91 100644
--- a/tests/test_tool_approval.py
+++ b/tests/test_tool_approval.py
@@ -215,6 +215,30 @@ class TestHandlerApprovalPause:
]
assert len(approval_events) == 1
+ def test_approval_event_carries_what_will_be_sent(self):
+ handler = ConcreteHandler()
+ agent = self._make_agent({
+ "call_id": "c1",
+ "name": "send_msg_0",
+ "tool_name": "telegram",
+ "tool_id": "0",
+ "action_name": "send_msg",
+ "arguments": {"text": "hello", "chat_id": "666"},
+ "sent_arguments": {"text": "hello", "chat_id": "111"},
+ "pause_type": "awaiting_approval",
+ "thought_signature": None,
+ })
+ call = ToolCall(id="c1", name="send_msg_0", arguments='{"text": "hello"}')
+ gen = handler.handle_tool_calls(agent, [call], {"0": {"name": "telegram"}}, [])
+ events = []
+ try:
+ while True:
+ events.append(next(gen))
+ except StopIteration:
+ pass
+ (event,) = [e for e in events if e.get("data", {}).get("status") == "awaiting_approval"]
+ assert event["data"]["sent_arguments"] == {"text": "hello", "chat_id": "111"}
+
def test_mixed_normal_and_approval(self):
"""First tool runs normally, second needs approval."""
handler = ConcreteHandler()