Skip to content

Commit 2dd52f7

Browse files
committed
Implement agent logging context manager and enhance agent execution logging
Add a context manager for binding per-agent execution fields to structlog contextvars in logging.py. Update the execute_agent function in executor.py to utilize this context manager, improving traceability and logging of agent execution events, including input and output messages.
1 parent 67a8ec2 commit 2dd52f7

3 files changed

Lines changed: 187 additions & 44 deletions

File tree

src/sub_agent_mcp/agent/executor.py

Lines changed: 55 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
from __future__ import annotations
44

5+
import uuid
6+
57
from sub_agent_mcp.agent.builder import build_agent, get_recursion_limit
68
from sub_agent_mcp.agent.errors import AgentExecutionError, AgentNotFoundError
79
from sub_agent_mcp.config.schema import AgentConfig, AgentsFile
8-
from sub_agent_mcp.logging import get_logger
10+
from sub_agent_mcp.logging import agent_log_context, get_logger
911

1012
logger = get_logger(__name__)
1113

@@ -24,49 +26,58 @@ async def execute_agent(agent: AgentConfig, prompt: str) -> str:
2426
from langchain_core.messages import HumanMessage
2527
from openai import AuthenticationError, OpenAIError
2628

27-
logger.info("spawn_agent_start", agent_id=agent.id)
28-
29-
try:
30-
runnable = await build_agent(agent)
31-
result = await runnable.ainvoke(
32-
{"messages": [HumanMessage(content=prompt)]},
33-
config={"recursion_limit": get_recursion_limit()},
34-
)
35-
except AuthenticationError as exc:
36-
logger.error("agent_auth_failed", agent_id=agent.id)
37-
raise AgentExecutionError(
38-
f"Invalid credentials for agent '{agent.id}' LLM provider"
39-
) from exc
40-
except OpenAIError as exc:
41-
logger.error("agent_llm_error", agent_id=agent.id, error=str(exc))
42-
raise AgentExecutionError(f"LLM error for agent '{agent.id}': {exc}") from exc
43-
except Exception as exc:
44-
if "401" in str(exc) or "authentication" in str(exc).lower():
45-
logger.error("agent_auth_failed", agent_id=agent.id)
29+
trace_id = str(uuid.uuid4())
30+
31+
with agent_log_context(
32+
trace_id=trace_id,
33+
model_id=agent.llm.model_id,
34+
agent_id=agent.id,
35+
):
36+
logger.info("spawn_agent_start")
37+
logger.info("agent_input", input=prompt)
38+
39+
try:
40+
runnable = await build_agent(agent)
41+
result = await runnable.ainvoke(
42+
{"messages": [HumanMessage(content=prompt)]},
43+
config={"recursion_limit": get_recursion_limit()},
44+
)
45+
except AuthenticationError as exc:
46+
logger.error("agent_auth_failed")
4647
raise AgentExecutionError(
4748
f"Invalid credentials for agent '{agent.id}' LLM provider"
4849
) from exc
49-
logger.error("agent_execution_failed", agent_id=agent.id, error=str(exc))
50-
raise AgentExecutionError(f"Agent execution failed for '{agent.id}': {exc}") from exc
51-
52-
messages = result.get("messages", [])
53-
if not messages:
54-
raise AgentExecutionError(f"Agent '{agent.id}' returned no messages")
55-
56-
final_message = messages[-1]
57-
content = getattr(final_message, "content", None)
58-
if content is None:
59-
raise AgentExecutionError(f"Agent '{agent.id}' returned empty content")
60-
61-
if isinstance(content, list):
62-
text_parts = [
63-
part.get("text", "")
64-
for part in content
65-
if isinstance(part, dict) and part.get("type") == "text"
66-
]
67-
response = "\n".join(part for part in text_parts if part)
68-
else:
69-
response = str(content)
70-
71-
logger.info("spawn_agent_complete", agent_id=agent.id)
72-
return response
50+
except OpenAIError as exc:
51+
logger.error("agent_llm_error", error=str(exc))
52+
raise AgentExecutionError(f"LLM error for agent '{agent.id}': {exc}") from exc
53+
except Exception as exc:
54+
if "401" in str(exc) or "authentication" in str(exc).lower():
55+
logger.error("agent_auth_failed")
56+
raise AgentExecutionError(
57+
f"Invalid credentials for agent '{agent.id}' LLM provider"
58+
) from exc
59+
logger.error("agent_execution_failed", error=str(exc))
60+
raise AgentExecutionError(f"Agent execution failed for '{agent.id}': {exc}") from exc
61+
62+
messages = result.get("messages", [])
63+
if not messages:
64+
raise AgentExecutionError(f"Agent '{agent.id}' returned no messages")
65+
66+
final_message = messages[-1]
67+
content = getattr(final_message, "content", None)
68+
if content is None:
69+
raise AgentExecutionError(f"Agent '{agent.id}' returned empty content")
70+
71+
if isinstance(content, list):
72+
text_parts = [
73+
part.get("text", "")
74+
for part in content
75+
if isinstance(part, dict) and part.get("type") == "text"
76+
]
77+
response = "\n".join(part for part in text_parts if part)
78+
else:
79+
response = str(content)
80+
81+
logger.info("agent_output", output=response)
82+
logger.info("spawn_agent_complete")
83+
return response

src/sub_agent_mcp/logging.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
import logging
66
import sys
7+
from collections.abc import Iterator
8+
from contextlib import contextmanager
79

810
import structlog
911

@@ -32,3 +34,17 @@ def setup_logging(level: str = "INFO") -> None:
3234

3335
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
3436
return structlog.get_logger(name)
37+
38+
39+
@contextmanager
40+
def agent_log_context(*, trace_id: str, model_id: str, agent_id: str) -> Iterator[None]:
41+
"""Bind per-agent execution fields to structlog contextvars."""
42+
structlog.contextvars.bind_contextvars(
43+
trace_id=trace_id,
44+
model_id=model_id,
45+
agent_id=agent_id,
46+
)
47+
try:
48+
yield
49+
finally:
50+
structlog.contextvars.clear_contextvars()

tests/test_logging.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Tests for agent execution logging."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from collections.abc import Iterator
7+
from contextlib import contextmanager
8+
from types import SimpleNamespace
9+
from unittest.mock import AsyncMock, patch
10+
11+
import pytest
12+
import structlog
13+
from structlog.testing import LogCapture
14+
15+
import sub_agent_mcp.agent.executor as executor_module
16+
from sub_agent_mcp.agent.errors import AgentExecutionError
17+
from sub_agent_mcp.agent.executor import spawn_agent
18+
from sub_agent_mcp.config.schema import AgentsFile
19+
from sub_agent_mcp.logging import get_logger
20+
21+
22+
@contextmanager
23+
def capture_agent_logs() -> Iterator[list[dict[str, object]]]:
24+
"""Capture structlog events including contextvars-bound fields."""
25+
cap = LogCapture()
26+
structlog.configure(
27+
processors=[
28+
structlog.contextvars.merge_contextvars,
29+
structlog.processors.add_log_level,
30+
cap,
31+
],
32+
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
33+
logger_factory=structlog.stdlib.LoggerFactory(),
34+
cache_logger_on_first_use=False,
35+
)
36+
executor_module.logger = get_logger(executor_module.__name__)
37+
try:
38+
yield cap.entries
39+
finally:
40+
structlog.reset_defaults()
41+
42+
43+
def _events_by_name(logs: list[dict[str, object]]) -> dict[str, list[dict[str, object]]]:
44+
grouped: dict[str, list[dict[str, object]]] = {}
45+
for entry in logs:
46+
event = entry["event"]
47+
assert isinstance(event, str)
48+
grouped.setdefault(event, []).append(entry)
49+
return grouped
50+
51+
52+
@pytest.mark.asyncio
53+
async def test_spawn_agent_success_logs_trace_model_and_io(agents_config: AgentsFile) -> None:
54+
mock_runnable = AsyncMock()
55+
mock_runnable.ainvoke.return_value = {
56+
"messages": [SimpleNamespace(content="Research complete.")]
57+
}
58+
prompt = "Find info on MCP"
59+
60+
with (
61+
patch("sub_agent_mcp.agent.executor.build_agent", return_value=mock_runnable),
62+
capture_agent_logs() as logs,
63+
):
64+
response = await spawn_agent(agents_config, "researcher", prompt)
65+
66+
assert response == "Research complete."
67+
68+
events = _events_by_name(logs)
69+
trace_ids = {entry["trace_id"] for entry in logs if "trace_id" in entry}
70+
assert len(trace_ids) == 1
71+
72+
trace_id = trace_ids.pop()
73+
start_logs = events["spawn_agent_start"]
74+
assert start_logs[0]["model_id"] == "gpt-4.1-mini"
75+
assert start_logs[0]["agent_id"] == "researcher"
76+
assert start_logs[0]["trace_id"] == trace_id
77+
78+
input_logs = events["agent_input"]
79+
assert input_logs[0]["input"] == prompt
80+
assert input_logs[0]["trace_id"] == trace_id
81+
82+
output_logs = events["agent_output"]
83+
assert output_logs[0]["output"] == "Research complete."
84+
assert output_logs[0]["trace_id"] == trace_id
85+
86+
complete_logs = events["spawn_agent_complete"]
87+
assert complete_logs[0]["trace_id"] == trace_id
88+
89+
90+
@pytest.mark.asyncio
91+
async def test_spawn_agent_auth_error_logs_input_with_trace_id(agents_config: AgentsFile) -> None:
92+
mock_runnable = AsyncMock()
93+
mock_runnable.ainvoke.side_effect = Exception("401 Unauthorized")
94+
prompt = "Hello"
95+
96+
with (
97+
patch("sub_agent_mcp.agent.executor.build_agent", return_value=mock_runnable),
98+
capture_agent_logs() as logs,
99+
pytest.raises(AgentExecutionError, match="Invalid credentials"),
100+
):
101+
await spawn_agent(agents_config, "researcher", prompt)
102+
103+
events = _events_by_name(logs)
104+
trace_ids = {entry["trace_id"] for entry in logs if "trace_id" in entry}
105+
assert len(trace_ids) == 1
106+
trace_id = trace_ids.pop()
107+
108+
input_logs = events["agent_input"]
109+
assert input_logs[0]["input"] == prompt
110+
assert input_logs[0]["trace_id"] == trace_id
111+
112+
error_logs = events["agent_auth_failed"]
113+
assert error_logs[0]["trace_id"] == trace_id
114+
assert error_logs[0]["model_id"] == "gpt-4.1-mini"
115+
116+
assert "agent_output" not in events

0 commit comments

Comments
 (0)