Skip to content

Commit ff524ac

Browse files
authored
Merge pull request #60 from rosspeili/feat/issue-56-session-skill-ux
feat: add structured skill execution session logging
2 parents 7c96ab9 + a1a1307 commit ff524ac

4 files changed

Lines changed: 196 additions & 17 deletions

File tree

rooms/agent.py

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def __init__(self, config: AgentConfig):
2525
self.system_prompt = config.system_prompt
2626
self.expertise = config.expertise
2727
self._skill_runtime = SkillRuntime(config)
28+
self._last_skill_events: List[Dict[str, Any]] = []
2829

2930
def _execute_custom_function(self, messages: List[Dict[str, str]]) -> str:
3031
"""Dynamically loads and invokes a custom python function for inference."""
@@ -62,11 +63,20 @@ def _execute_custom_function(self, messages: List[Dict[str, str]]) -> str:
6263
logger.error(f"Error executing custom function '{func_name}' in {file_path}: {e}")
6364
return f"[Error: Custom function failed. Details: {str(e)}]"
6465

65-
def generate_response(self, context_messages: List[Dict[str, str]], override_params: Optional[Dict[str, Any]] = None) -> str:
66+
def consume_last_skill_events(self) -> List[Dict[str, Any]]:
67+
"""Return and clear structured skill events for the last generation call."""
68+
events = list(self._last_skill_events)
69+
self._last_skill_events = []
70+
return events
71+
72+
def generate_response_with_events(
73+
self, context_messages: List[Dict[str, str]], override_params: Optional[Dict[str, Any]] = None
74+
) -> Dict[str, Any]:
6675
"""
67-
Generate a response using LiteLLM or a custom function.
76+
Generate a response and structured skill execution events.
6877
"""
6978
params = override_params or {}
79+
self._last_skill_events = []
7080

7181
# Build the system message for this agent
7282
full_system_prompt = self.config.system_prompt
@@ -85,7 +95,8 @@ def generate_response(self, context_messages: List[Dict[str, str]], override_par
8595
messages.extend(context_messages)
8696

8797
if self.model_type == ModelType.CUSTOM_FUNCTION:
88-
return self._execute_custom_function(messages)
98+
content = self._execute_custom_function(messages)
99+
return {"content": content, "skill_events": []}
89100

90101
try:
91102
# LiteLLM handling
@@ -101,7 +112,7 @@ def generate_response(self, context_messages: List[Dict[str, str]], override_par
101112
litellm_params.update(params)
102113
tools = self._skill_runtime.get_tools()
103114
if self._skill_runtime.has_skills and self._skill_runtime.load_error:
104-
return f"[Error: {self._skill_runtime.load_error}]"
115+
return {"content": f"[Error: {self._skill_runtime.load_error}]", "skill_events": []}
105116
if tools:
106117
litellm_params["tools"] = tools
107118
litellm_params["tool_choice"] = "auto"
@@ -112,13 +123,17 @@ def generate_response(self, context_messages: List[Dict[str, str]], override_par
112123

113124
if tools and tool_calls:
114125
if len(tool_calls) > self.config.max_skill_calls_per_turn:
115-
return (
116-
"[Error: Model requested too many tool calls in one turn "
117-
f"({len(tool_calls)} > {self.config.max_skill_calls_per_turn})]"
118-
)
126+
return {
127+
"content": (
128+
"[Error: Model requested too many tool calls in one turn "
129+
f"({len(tool_calls)} > {self.config.max_skill_calls_per_turn})]"
130+
),
131+
"skill_events": [],
132+
}
119133

120134
tool_call_payload: List[Dict[str, Any]] = []
121135
tool_results: List[Dict[str, Any]] = []
136+
skill_events: List[Dict[str, Any]] = []
122137
for tc in tool_calls:
123138
func = getattr(tc, "function", None)
124139
tool_name = getattr(func, "name", "")
@@ -128,6 +143,15 @@ def generate_response(self, context_messages: List[Dict[str, str]], override_par
128143
except Exception: # noqa: BLE001
129144
parsed_args = {}
130145
execution = self._skill_runtime.execute_tool(tool_name, parsed_args, self.config.timeout)
146+
skill_events.append(
147+
{
148+
"event_type": "skill_execution",
149+
"tool_name": tool_name,
150+
"arguments": parsed_args,
151+
"result": execution,
152+
"ok": bool(execution.get("ok")),
153+
}
154+
)
131155

132156
tc_id = getattr(tc, "id", f"call_{len(tool_call_payload)}")
133157
tool_call_payload.append(
@@ -149,16 +173,28 @@ def generate_response(self, context_messages: List[Dict[str, str]], override_par
149173
second_params = dict(litellm_params)
150174
second_params["messages"] = messages
151175
second_response = litellm.completion(**second_params)
152-
return (second_response.choices[0].message.content or "").strip()
176+
content = (second_response.choices[0].message.content or "").strip()
177+
self._last_skill_events = skill_events
178+
return {"content": content, "skill_events": skill_events}
153179

154-
return (first_message.content or "").strip()
180+
return {"content": (first_message.content or "").strip(), "skill_events": []}
155181

156182
except litellm.Timeout as e:
157183
logger.error(f"Timeout logic executed for agent '{self.name}' on model '{self.model}': {e}")
158-
return f"[Timeout Error: The model '{self.model}' took too long to respond ({self.config.timeout}s)]"
184+
return {
185+
"content": f"[Timeout Error: The model '{self.model}' took too long to respond ({self.config.timeout}s)]",
186+
"skill_events": [],
187+
}
159188
except Exception as e:
160189
logger.error(f"Error getting response from agent '{self.name}' on model '{self.model}': {e}")
161-
return f"[Error: Could not generate response. Details: {str(e)}]"
190+
return {"content": f"[Error: Could not generate response. Details: {str(e)}]", "skill_events": []}
191+
192+
def generate_response(self, context_messages: List[Dict[str, str]], override_params: Optional[Dict[str, Any]] = None) -> str:
193+
"""
194+
Backward-compatible wrapper that returns only human-readable content.
195+
"""
196+
result = self.generate_response_with_events(context_messages, override_params=override_params)
197+
return str(result.get("content", "")).strip()
162198

163199
def __repr__(self):
164200
return f"<Agent name={self.name} model={self.model}>"

rooms/session.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def __init__(self, config: SessionConfig, agents: List[Agent], user_profile: Opt
5555
self.config = config
5656
self.agents = agents
5757
self.user_profile = user_profile # {"name": "...", "background": "..."}
58-
self.history: List[Dict[str, str]] = []
58+
self.history: List[Dict[str, Any]] = []
5959
self.turn_count = 0
6060
self._last_orchestrator_turn = -1
6161
self._forced_next_agent: Optional[Agent] = None # Locked next agent from @mention or user direction
@@ -91,6 +91,9 @@ def get_agent_context(self, current_agent: Agent) -> List[Dict[str, str]]:
9191
"""Format history into an LLM context including system prompt."""
9292
context = []
9393
for msg in self.history:
94+
if msg.get("role") == "skill":
95+
# Keep tool logs in session history/transcripts, but out of model context.
96+
continue
9497
role = "user"
9598
if msg["role"] == current_agent.name:
9699
role = "assistant"
@@ -104,6 +107,27 @@ def get_agent_context(self, current_agent: Agent) -> List[Dict[str, str]]:
104107
context.append({"role": role, "content": content})
105108
return context
106109

110+
def _append_skill_events(self, agent: Agent, skill_events: List[Dict[str, Any]]) -> None:
111+
"""Store structured skill execution events in session history."""
112+
for event in skill_events:
113+
result = event.get("result", {})
114+
tool_name = event.get("tool_name", "unknown_tool")
115+
status = "ok" if event.get("ok") else "error"
116+
content = f"{agent.name} used {tool_name} ({status})"
117+
self.history.append(
118+
{
119+
"role": "skill",
120+
"agent": agent.name,
121+
"event_type": event.get("event_type", "skill_execution"),
122+
"tool_name": tool_name,
123+
"arguments": event.get("arguments", {}),
124+
"result": result,
125+
"status": status,
126+
"content": content,
127+
"timestamp": _now(),
128+
}
129+
)
130+
107131
def generate_next_turn(self) -> Optional[Dict[str, str]]:
108132
"""Determine next agent, get response, and log it."""
109133
if self.turn_count >= self.config.max_turns:
@@ -141,6 +165,9 @@ def generate_next_turn(self) -> Optional[Dict[str, str]]:
141165

142166
context = self.get_agent_context(agent)
143167
response_text = agent.generate_response(context)
168+
skill_events = agent.consume_last_skill_events() if hasattr(agent, "consume_last_skill_events") else []
169+
if skill_events:
170+
self._append_skill_events(agent, skill_events)
144171

145172
# Handle PASS: agent has nothing to add — silently skip turn
146173
if response_text.strip().upper() == "PASS":
@@ -171,7 +198,8 @@ def _select_next_agent(self) -> Agent:
171198

172199
elif self.config.session_type == SessionType.DYNAMIC:
173200
# Build context text from recent history for scoring
174-
recent = " ".join(m["content"] for m in self.history[-5:])
201+
recent_messages = [m for m in self.history if m.get("role") != "skill"]
202+
recent = " ".join(m.get("content", "") for m in recent_messages[-5:])
175203

176204
# 1. Check for @mention or name reference in last user/agent message
177205
if self.history:

rooms/storage.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import csv
2+
import json
23
import os
3-
from typing import List, Dict
4+
from typing import List, Dict, Any
45

56

67
def slugify_topic(topic: str, max_words: int = 5) -> str:
@@ -12,7 +13,7 @@ def slugify_topic(topic: str, max_words: int = 5) -> str:
1213
return slug or "session"
1314

1415

15-
def save_transcript(history: List[Dict[str, str]], filepath: str, format: str = "markdown"):
16+
def save_transcript(history: List[Dict[str, Any]], filepath: str, format: str = "markdown"):
1617
"""
1718
Save the conversation history to filepath.
1819
Format: 'markdown' or 'csv'.
@@ -28,10 +29,22 @@ def save_transcript(history: List[Dict[str, str]], filepath: str, format: str =
2829
writer = csv.writer(f)
2930
writer.writerow(["Timestamp", "Speaker", "Message"])
3031
for msg in public_history:
32+
if msg.get("role") == "skill":
33+
payload = {
34+
"event_type": msg.get("event_type", "skill_execution"),
35+
"agent": msg.get("agent", ""),
36+
"tool_name": msg.get("tool_name", ""),
37+
"status": msg.get("status", ""),
38+
"arguments": msg.get("arguments", {}),
39+
"result": msg.get("result", {}),
40+
}
41+
message = json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
42+
else:
43+
message = msg.get("content", "").replace("\n", " ")
3144
writer.writerow([
3245
msg.get("timestamp", ""),
3346
msg.get("role", ""),
34-
msg.get("content", "").replace("\n", " ")
47+
message
3548
])
3649
else:
3750
# Markdown
@@ -42,4 +55,13 @@ def save_transcript(history: List[Dict[str, str]], filepath: str, format: str =
4255
content = msg.get("content", "")
4356
ts = msg.get("timestamp", "")
4457
ts_str = f" _{ts}_" if ts else ""
58+
if role == "skill":
59+
content = (
60+
f"- agent: {msg.get('agent', '')}\n"
61+
f"- tool: {msg.get('tool_name', '')}\n"
62+
f"- status: {msg.get('status', '')}\n"
63+
f"- arguments: `{json.dumps(msg.get('arguments', {}), ensure_ascii=True)}`\n"
64+
f"- result: `{json.dumps(msg.get('result', {}), ensure_ascii=True)}`"
65+
)
66+
role = "skill event"
4567
f.write(f"### {role.strip().capitalize()}{ts_str}\n\n{content}\n\n---\n\n")

tests/test_session.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import pytest
2+
import tempfile
3+
from pathlib import Path
24
from unittest.mock import MagicMock
35
from rooms.config import SessionConfig, AgentConfig, SessionType
46
from rooms.agent import Agent
57
from rooms.session import Session, _score_agent_expertise
8+
from rooms.storage import save_transcript
69

710
def test_round_robin_session():
811
# Setup
@@ -318,3 +321,93 @@ def test_hitl_trigger_only_once_per_message():
318321

319322
session.add_user_message("Theo", "Hello")
320323
assert session.needs_human_input() is False
324+
325+
326+
def test_session_logs_structured_skill_events_and_keeps_reply_human_readable():
327+
config = SessionConfig(
328+
topic="Skill Logging Test",
329+
agents=[AgentConfig(name="AgentA", system_prompt="sys")],
330+
session_type=SessionType.ROUND_ROBIN,
331+
max_turns=1,
332+
)
333+
agent_a = Agent(config.agents[0])
334+
agent_a.generate_response = MagicMock(return_value="Human-readable answer")
335+
agent_a.consume_last_skill_events = MagicMock(
336+
return_value=[
337+
{
338+
"event_type": "skill_execution",
339+
"tool_name": "finance_wallet_screening",
340+
"arguments": {"wallet": "0xabc"},
341+
"result": {"ok": True, "data": {"flagged": True}},
342+
"ok": True,
343+
}
344+
]
345+
)
346+
session = Session(config, [agent_a])
347+
348+
turn = session.generate_next_turn()
349+
assert turn["content"] == "Human-readable answer"
350+
351+
skill_logs = [m for m in session.history if m.get("role") == "skill"]
352+
assert len(skill_logs) == 1
353+
assert skill_logs[0]["tool_name"] == "finance_wallet_screening"
354+
assert skill_logs[0]["status"] == "ok"
355+
356+
357+
def test_skill_events_are_excluded_from_agent_context():
358+
config = SessionConfig(
359+
topic="Skill Context Isolation",
360+
agents=[AgentConfig(name="AgentA", system_prompt="sys")],
361+
session_type=SessionType.ROUND_ROBIN,
362+
max_turns=1,
363+
)
364+
agent_a = Agent(config.agents[0])
365+
session = Session(config, [agent_a])
366+
session.history.append(
367+
{
368+
"role": "skill",
369+
"agent": "AgentA",
370+
"event_type": "skill_execution",
371+
"tool_name": "finance_wallet_screening",
372+
"arguments": {"wallet": "0xabc"},
373+
"result": {"ok": True},
374+
"status": "ok",
375+
"content": "AgentA used finance_wallet_screening (ok)",
376+
"timestamp": "2026-06-17 12:00:00",
377+
}
378+
)
379+
380+
context = session.get_agent_context(agent_a)
381+
assert all("finance_wallet_screening" not in msg["content"] for msg in context)
382+
383+
384+
def test_transcript_writer_persists_skill_events():
385+
history = [
386+
{"role": "system", "content": "bootstrap", "timestamp": "2026-06-17 12:00:00"},
387+
{"role": "AgentA", "content": "Normal answer", "timestamp": "2026-06-17 12:00:01"},
388+
{
389+
"role": "skill",
390+
"agent": "AgentA",
391+
"event_type": "skill_execution",
392+
"tool_name": "finance_wallet_screening",
393+
"arguments": {"wallet": "0xabc"},
394+
"result": {"ok": True, "data": {"flagged": True}},
395+
"status": "ok",
396+
"content": "AgentA used finance_wallet_screening (ok)",
397+
"timestamp": "2026-06-17 12:00:02",
398+
},
399+
]
400+
401+
with tempfile.TemporaryDirectory() as td:
402+
md_path = Path(td) / "session.md"
403+
csv_path = Path(td) / "session.csv"
404+
405+
save_transcript(history, str(md_path), format="markdown")
406+
save_transcript(history, str(csv_path), format="csv")
407+
408+
md_text = md_path.read_text(encoding="utf-8")
409+
csv_text = csv_path.read_text(encoding="utf-8")
410+
411+
assert "Skill event" in md_text
412+
assert "finance_wallet_screening" in md_text
413+
assert 'tool_name"":""finance_wallet_screening' in csv_text

0 commit comments

Comments
 (0)