소스 검색

feat: add rich agent audit logs

zhenyu.hu 3 주 전
부모
커밋
37db756ef5
4개의 변경된 파일177개의 추가작업 그리고 0개의 파일을 삭제
  1. 24 0
      docs/plans/todo-29-rich-audit-log.md
  2. 1 0
      docs/plans/todos.md
  3. 83 0
      src/agent_lab/application/runtime.py
  4. 69 0
      tests/test_debug_runtime.py

+ 24 - 0
docs/plans/todo-29-rich-audit-log.md

@@ -0,0 +1,24 @@
+# Todo 29: Rich Audit Log
+
+## Status
+
+done
+
+## Goal
+
+Make the debug console audit trail useful for real LLM debugging. A user should be able to see which model parameters and prompts/messages were sent, what text/events/results came back, and what usage was reported.
+
+## Scope
+
+- Add ChatAgent request audit details: agent, turn/round, model params, messages, and tools.
+- Add ChatAgent response audit details: text content, event names, and usage.
+- Add EventAgent request audit details: agent params, requested events, history, system prompt, extra body, and tool schemas.
+- Add EventAgent response audit details: tool reply messages.
+- Keep API keys and Authorization headers out of audit output.
+
+## Verification
+
+- Runtime tests assert audit payloads contain model params, prompt/messages, event/tool result data, and usage.
+- Existing audit/log tests still pass with the richer event stream.
+- `uv run pytest tests/test_debug_runtime.py -q` passes: 17 tests.
+- `uv run pytest` passes: 57 tests.

+ 1 - 0
docs/plans/todos.md

@@ -55,3 +55,4 @@
 | 26 | done | `docs/plans/todo-26-workspace-snapshot-save.md` | Move save/load/delete out of ChatAgent config and save one workspace snapshot covering prompts, Agent config, and selected tools. | `uv run pytest tests/test_websocket_api.py -q` passes (`27 passed`, one existing Starlette deprecation warning). |
 | 27 | done | `docs/plans/todo-27-event-agent-tool-arguments.md` | Make EventAgent tool argument generation robust for compatible providers that do not emit the expected tool-call finish reason or ignore tool calls. | `uv run pytest` passes (`53 passed`, one existing Starlette deprecation warning). |
 | 28 | done | `docs/plans/todo-28-session-turns.md` | Split WebSocket session lifetime from user turns so one conversation can run multiple user messages while each turn has its own event budget. | `uv run pytest` passes (`56 passed`, one existing Starlette deprecation warning). |
+| 29 | done | `docs/plans/todo-29-rich-audit-log.md` | Add audit entries for ChatAgent/EventAgent model parameters, prompts/messages, results, and usage without logging secrets. | `uv run pytest` passes (`57 passed`, one existing Starlette deprecation warning). |

+ 83 - 0
src/agent_lab/application/runtime.py

@@ -157,6 +157,15 @@ class DebugRuntime:
                 round_index=round_index,
                 events_enabled=enabled_events,
             )
+            await self._audit(
+                queues,
+                "chat_agent_request",
+                agent="chat_agent",
+                round_index=round_index,
+                params=self._params_snapshot(request.chat_agent),
+                messages=self._message_snapshots(chat_messages),
+                tools=[],
+            )
 
             async for item in self.chat_client.stream_chat(
                 messages=chat_messages,
@@ -197,6 +206,21 @@ class DebugRuntime:
                         event_name=event.name,
                     )
 
+            await self._audit(
+                queues,
+                "chat_agent_response",
+                agent="chat_agent",
+                round_index=round_index,
+                content="".join(assistant_content),
+                event_names=[event.name for event in events],
+                usage={
+                    "prompt_tokens": prompt_tokens,
+                    "completion_tokens": completion_tokens,
+                    "total_tokens": total_tokens,
+                    "cached_tokens": cached_tokens,
+                },
+            )
+
             if assistant_content or events:
                 assistant_message = ChatMessage(
                     role="assistant",
@@ -330,6 +354,16 @@ class DebugRuntime:
                 round_index=round_index,
                 events_enabled=enabled_events,
             )
+            await self._audit(
+                queues,
+                "chat_agent_request",
+                agent="chat_agent",
+                turn_index=turn_index,
+                round_index=round_index,
+                params=self._params_snapshot(request.chat_agent),
+                messages=self._message_snapshots(chat_messages),
+                tools=[],
+            )
 
             async for item in self.chat_client.stream_chat(
                 messages=chat_messages,
@@ -371,6 +405,22 @@ class DebugRuntime:
                         event_name=event.name,
                     )
 
+            await self._audit(
+                queues,
+                "chat_agent_response",
+                agent="chat_agent",
+                turn_index=turn_index,
+                round_index=round_index,
+                content="".join(assistant_content),
+                event_names=[event.name for event in events],
+                usage={
+                    "prompt_tokens": prompt_tokens,
+                    "completion_tokens": completion_tokens,
+                    "total_tokens": total_tokens,
+                    "cached_tokens": cached_tokens,
+                },
+            )
+
             if assistant_content or events:
                 messages.append(
                     ChatMessage(
@@ -459,12 +509,36 @@ class DebugRuntime:
             request = await queues.events.get()
             if request is None:
                 return
+            await self._audit(
+                queues,
+                "event_agent_request",
+                agent="event_agent",
+                params=self._params_snapshot(event_agent.params),
+                events=self._event_snapshots(request.events),
+                history=self._message_snapshots(request.history),
+                system_prompt=request.system_prompt,
+                extra_body=request.extra_body or {},
+                tools=[
+                    tool
+                    for tool in (
+                        event_agent.registry.tool_schema(event.name)
+                        for event in request.events
+                    )
+                    if tool is not None
+                ],
+            )
             replies = await event_agent.handle_many(
                 request.events,
                 history=request.history,
                 system_prompt=request.system_prompt,
                 extra_body=request.extra_body,
             )
+            await self._audit(
+                queues,
+                "event_agent_response",
+                agent="event_agent",
+                replies=[reply.model_dump() for reply in replies],
+            )
             for reply in replies:
                 await queues.input.put(reply)
             summary = event_agent.summarize_replies(replies)
@@ -555,6 +629,15 @@ class DebugRuntime:
     def _elapsed_ms(self, started_at: float) -> int:
         return round((self.clock() - started_at) * 1000)
 
+    def _params_snapshot(self, params: AgentParams) -> dict[str, Any]:
+        return params.model_dump()
+
+    def _message_snapshots(self, messages: list[ChatMessage] | tuple[ChatMessage, ...]) -> list[dict[str, Any]]:
+        return [message.model_dump() for message in messages]
+
+    def _event_snapshots(self, events: list[ToolCallEvent]) -> list[dict[str, Any]]:
+        return [event.model_dump() for event in events]
+
     async def _audit(
         self,
         queues: RuntimeQueues,

+ 69 - 0
tests/test_debug_runtime.py

@@ -436,10 +436,16 @@ async def test_runtime_emits_audit_events_and_backend_logs(caplog):
     assert audit_events == [
         "session_started",
         "chat_round_started",
+        "chat_agent_request",
         "chat_event_detected",
+        "chat_agent_response",
+        "event_agent_request",
+        "event_agent_response",
         "event_agent_completed",
         "chat_round_finished",
         "chat_round_started",
+        "chat_agent_request",
+        "chat_agent_response",
         "chat_round_finished",
         "session_finished",
     ]
@@ -447,6 +453,69 @@ async def test_runtime_emits_audit_events_and_backend_logs(caplog):
     assert "event_agent_completed" in caplog.text
 
 
+@pytest.mark.asyncio
+async def test_runtime_audit_includes_model_params_prompts_results_and_usage():
+    request = DebugRunRequest(
+        user_message="debug this",
+        system_prompts=["You are a debugger."],
+        pre_messages=[],
+        chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
+        event_agent=EventAgentParams(
+            model="event-model",
+            temperature=0.4,
+            max_tokens=80,
+            enabled_tools=["handoff_note"],
+            max_event_loops=1,
+        ),
+    )
+    runtime = DebugRuntime(EventRoundStatsChatClient())
+
+    outputs = [message async for message in runtime.run(request)]
+    audits = [message for message in outputs if message["type"] == "audit"]
+
+    chat_request = next(
+        message for message in audits if message["event"] == "chat_agent_request"
+    )
+    assert chat_request["details"]["agent"] == "chat_agent"
+    assert chat_request["details"]["params"]["model"] == "chat-model"
+    assert chat_request["details"]["params"]["temperature"] == 0.1
+    assert chat_request["details"]["params"]["max_tokens"] == 200
+    assert chat_request["details"]["tools"] == []
+    assert chat_request["details"]["messages"][0] == {
+        "role": "system",
+        "content": "You are a debugger.",
+        "name": None,
+        "tool_call_id": None,
+    }
+
+    chat_response = next(
+        message for message in audits if message["event"] == "chat_agent_response"
+    )
+    assert chat_response["details"]["event_names"] == ["handoff_note"]
+    assert chat_response["details"]["usage"] == {
+        "prompt_tokens": 3,
+        "completion_tokens": 0,
+        "total_tokens": 3,
+        "cached_tokens": 0,
+    }
+
+    event_request = next(
+        message for message in audits if message["event"] == "event_agent_request"
+    )
+    assert event_request["details"]["agent"] == "event_agent"
+    assert event_request["details"]["params"]["model"] == "event-model"
+    assert event_request["details"]["params"]["temperature"] == 0.4
+    assert event_request["details"]["events"][0]["name"] == "handoff_note"
+    assert event_request["details"]["tools"][0]["function"]["name"] == "handoff_note"
+    assert "Authorization" not in str(event_request["details"])
+
+    event_response = next(
+        message for message in audits if message["event"] == "event_agent_response"
+    )
+    assert event_response["details"]["replies"][0]["role"] == "tool"
+    assert '"tool": "handoff_note"' in event_response["details"]["replies"][0]["content"]
+
+
 @pytest.mark.asyncio
 async def test_runtime_outputs_event_as_soon_as_chat_stream_detects_it():
     RuntimeQueues = _runtime_queues_class()