removed support for nested subagent
Browse files- messaging/transcript.py +47 -55
- tests/test_transcript.py +146 -0
messaging/transcript.py
CHANGED
|
@@ -138,40 +138,30 @@ class ToolResultSegment(Segment):
|
|
| 138 |
@dataclass
|
| 139 |
class SubagentSegment(Segment):
|
| 140 |
description: str
|
| 141 |
-
indent_level: int = 0
|
| 142 |
tool_calls: int = 0
|
| 143 |
tools_used: set[str] = field(default_factory=set)
|
| 144 |
current_tool: Optional[ToolCallSegment] = None
|
| 145 |
-
subagents: List["SubagentSegment"] = field(default_factory=list)
|
| 146 |
|
| 147 |
-
def __init__(self, description: str
|
| 148 |
super().__init__(kind="subagent")
|
| 149 |
self.description = str(description or "Subagent")
|
| 150 |
-
self.indent_level = max(0, int(indent_level))
|
| 151 |
self.tool_calls = 0
|
| 152 |
self.tools_used = set()
|
| 153 |
self.current_tool = None
|
| 154 |
-
self.subagents = []
|
| 155 |
|
| 156 |
def set_current_tool_call(self, tool_use_id: str, name: str) -> ToolCallSegment:
|
| 157 |
tool_use_id = str(tool_use_id or "")
|
| 158 |
name = str(name or "tool")
|
| 159 |
self.tools_used.add(name)
|
| 160 |
self.tool_calls += 1
|
| 161 |
-
|
| 162 |
-
self.current_tool
|
| 163 |
-
return seg
|
| 164 |
-
|
| 165 |
-
def add_subagent(self, seg: "SubagentSegment") -> None:
|
| 166 |
-
# Nesting just adds more indent: the nested SubagentSegment carries its own indent.
|
| 167 |
-
self.subagents.append(seg)
|
| 168 |
|
| 169 |
def render(self, ctx: "RenderCtx") -> str:
|
| 170 |
-
|
| 171 |
-
inner_prefix = " " * (self.indent_level + 1)
|
| 172 |
|
| 173 |
lines: List[str] = [
|
| 174 |
-
f"
|
| 175 |
]
|
| 176 |
|
| 177 |
if self.current_tool is not None:
|
|
@@ -182,14 +172,6 @@ class SubagentSegment(Segment):
|
|
| 182 |
if rendered:
|
| 183 |
lines.append(rendered)
|
| 184 |
|
| 185 |
-
for sub in self.subagents:
|
| 186 |
-
try:
|
| 187 |
-
rendered = sub.render(ctx)
|
| 188 |
-
except Exception:
|
| 189 |
-
continue
|
| 190 |
-
if rendered:
|
| 191 |
-
lines.append(rendered)
|
| 192 |
-
|
| 193 |
tools_used = sorted(self.tools_used)
|
| 194 |
if tools_used:
|
| 195 |
tools_set_raw = "{%s}" % (", ".join(tools_used))
|
|
@@ -257,9 +239,6 @@ class TranscriptBuffer:
|
|
| 257 |
def _in_subagent(self) -> bool:
|
| 258 |
return bool(self._subagent_stack)
|
| 259 |
|
| 260 |
-
def _subagent_depth(self) -> int:
|
| 261 |
-
return len(self._subagent_stack)
|
| 262 |
-
|
| 263 |
def _subagent_current(self) -> Optional[SubagentSegment]:
|
| 264 |
return self._subagent_segments[-1] if self._subagent_segments else None
|
| 265 |
|
|
@@ -292,13 +271,23 @@ class TranscriptBuffer:
|
|
| 292 |
getattr(seg, "description", None),
|
| 293 |
)
|
| 294 |
|
| 295 |
-
def _subagent_pop(self, tool_id: str) ->
|
| 296 |
tool_id = str(tool_id or "").strip()
|
| 297 |
if not self._subagent_stack:
|
| 298 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
if tool_id:
|
| 300 |
# O(1) common case: LIFO - top of stack matches.
|
| 301 |
-
if self._subagent_stack[-1]
|
| 302 |
self._subagent_stack.pop()
|
| 303 |
if self._subagent_segments:
|
| 304 |
self._subagent_segments.pop()
|
|
@@ -308,16 +297,15 @@ class TranscriptBuffer:
|
|
| 308 |
tool_id,
|
| 309 |
len(self._subagent_stack),
|
| 310 |
)
|
| 311 |
-
return
|
| 312 |
# Pop to the matching id (defensive against non-LIFO emissions).
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
return
|
| 321 |
while len(self._subagent_stack) > idx:
|
| 322 |
popped = self._subagent_stack.pop()
|
| 323 |
if self._subagent_segments:
|
|
@@ -329,7 +317,7 @@ class TranscriptBuffer:
|
|
| 329 |
len(self._subagent_stack),
|
| 330 |
tool_id,
|
| 331 |
)
|
| 332 |
-
return
|
| 333 |
|
| 334 |
# No id in result; only close if we have a synthetic top marker.
|
| 335 |
if self._subagent_stack and self._subagent_stack[-1].startswith("__task_"):
|
|
@@ -342,6 +330,8 @@ class TranscriptBuffer:
|
|
| 342 |
popped,
|
| 343 |
len(self._subagent_stack),
|
| 344 |
)
|
|
|
|
|
|
|
| 345 |
|
| 346 |
def _ensure_thinking(self) -> ThinkingSegment:
|
| 347 |
seg = ThinkingSegment()
|
|
@@ -428,12 +418,8 @@ class TranscriptBuffer:
|
|
| 428 |
# Task tool indicates subagent.
|
| 429 |
if name == "Task":
|
| 430 |
heading = self._task_heading_from_input(ev.get("input"))
|
| 431 |
-
seg = SubagentSegment(heading
|
| 432 |
-
|
| 433 |
-
if parent is not None:
|
| 434 |
-
parent.add_subagent(seg)
|
| 435 |
-
else:
|
| 436 |
-
self._segments.append(seg)
|
| 437 |
self._subagent_push(tool_id, seg)
|
| 438 |
return
|
| 439 |
|
|
@@ -490,12 +476,8 @@ class TranscriptBuffer:
|
|
| 490 |
|
| 491 |
if name == "Task":
|
| 492 |
heading = self._task_heading_from_input(ev.get("input"))
|
| 493 |
-
seg = SubagentSegment(heading
|
| 494 |
-
|
| 495 |
-
if parent is not None:
|
| 496 |
-
parent.add_subagent(seg)
|
| 497 |
-
else:
|
| 498 |
-
self._segments.append(seg)
|
| 499 |
self._subagent_push(tool_id, seg)
|
| 500 |
return
|
| 501 |
|
|
@@ -519,10 +501,20 @@ class TranscriptBuffer:
|
|
| 519 |
name = self._tool_name_by_id.get(tool_id)
|
| 520 |
|
| 521 |
# If this was the Task tool result, close subagent context.
|
| 522 |
-
if self._subagent_stack
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
|
| 527 |
if not self._show_tool_results:
|
| 528 |
return
|
|
|
|
| 138 |
@dataclass
|
| 139 |
class SubagentSegment(Segment):
|
| 140 |
description: str
|
|
|
|
| 141 |
tool_calls: int = 0
|
| 142 |
tools_used: set[str] = field(default_factory=set)
|
| 143 |
current_tool: Optional[ToolCallSegment] = None
|
|
|
|
| 144 |
|
| 145 |
+
def __init__(self, description: str) -> None:
|
| 146 |
super().__init__(kind="subagent")
|
| 147 |
self.description = str(description or "Subagent")
|
|
|
|
| 148 |
self.tool_calls = 0
|
| 149 |
self.tools_used = set()
|
| 150 |
self.current_tool = None
|
|
|
|
| 151 |
|
| 152 |
def set_current_tool_call(self, tool_use_id: str, name: str) -> ToolCallSegment:
|
| 153 |
tool_use_id = str(tool_use_id or "")
|
| 154 |
name = str(name or "tool")
|
| 155 |
self.tools_used.add(name)
|
| 156 |
self.tool_calls += 1
|
| 157 |
+
self.current_tool = ToolCallSegment(tool_use_id, name, indent_level=1)
|
| 158 |
+
return self.current_tool
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
def render(self, ctx: "RenderCtx") -> str:
|
| 161 |
+
inner_prefix = " "
|
|
|
|
| 162 |
|
| 163 |
lines: List[str] = [
|
| 164 |
+
f"π€ {ctx.bold('Subagent:')} {ctx.code_inline(self.description)}"
|
| 165 |
]
|
| 166 |
|
| 167 |
if self.current_tool is not None:
|
|
|
|
| 172 |
if rendered:
|
| 173 |
lines.append(rendered)
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
tools_used = sorted(self.tools_used)
|
| 176 |
if tools_used:
|
| 177 |
tools_set_raw = "{%s}" % (", ".join(tools_used))
|
|
|
|
| 239 |
def _in_subagent(self) -> bool:
|
| 240 |
return bool(self._subagent_stack)
|
| 241 |
|
|
|
|
|
|
|
|
|
|
| 242 |
def _subagent_current(self) -> Optional[SubagentSegment]:
|
| 243 |
return self._subagent_segments[-1] if self._subagent_segments else None
|
| 244 |
|
|
|
|
| 271 |
getattr(seg, "description", None),
|
| 272 |
)
|
| 273 |
|
| 274 |
+
def _subagent_pop(self, tool_id: str) -> bool:
|
| 275 |
tool_id = str(tool_id or "").strip()
|
| 276 |
if not self._subagent_stack:
|
| 277 |
+
return False
|
| 278 |
+
|
| 279 |
+
def _ids_roughly_match(stack_id: str, result_id: str) -> bool:
|
| 280 |
+
if not stack_id or not result_id:
|
| 281 |
+
return False
|
| 282 |
+
if stack_id == result_id:
|
| 283 |
+
return True
|
| 284 |
+
# Some providers emit Task result ids with a suffix/prefix variant.
|
| 285 |
+
# Treat those as the same logical Task invocation.
|
| 286 |
+
return stack_id.startswith(result_id) or result_id.startswith(stack_id)
|
| 287 |
+
|
| 288 |
if tool_id:
|
| 289 |
# O(1) common case: LIFO - top of stack matches.
|
| 290 |
+
if _ids_roughly_match(self._subagent_stack[-1], tool_id):
|
| 291 |
self._subagent_stack.pop()
|
| 292 |
if self._subagent_segments:
|
| 293 |
self._subagent_segments.pop()
|
|
|
|
| 297 |
tool_id,
|
| 298 |
len(self._subagent_stack),
|
| 299 |
)
|
| 300 |
+
return True
|
| 301 |
# Pop to the matching id (defensive against non-LIFO emissions).
|
| 302 |
+
idx = -1
|
| 303 |
+
for i in range(len(self._subagent_stack) - 1, -1, -1):
|
| 304 |
+
if _ids_roughly_match(self._subagent_stack[i], tool_id):
|
| 305 |
+
idx = i
|
| 306 |
+
break
|
| 307 |
+
if idx < 0:
|
| 308 |
+
return False
|
|
|
|
| 309 |
while len(self._subagent_stack) > idx:
|
| 310 |
popped = self._subagent_stack.pop()
|
| 311 |
if self._subagent_segments:
|
|
|
|
| 317 |
len(self._subagent_stack),
|
| 318 |
tool_id,
|
| 319 |
)
|
| 320 |
+
return True
|
| 321 |
|
| 322 |
# No id in result; only close if we have a synthetic top marker.
|
| 323 |
if self._subagent_stack and self._subagent_stack[-1].startswith("__task_"):
|
|
|
|
| 330 |
popped,
|
| 331 |
len(self._subagent_stack),
|
| 332 |
)
|
| 333 |
+
return True
|
| 334 |
+
return False
|
| 335 |
|
| 336 |
def _ensure_thinking(self) -> ThinkingSegment:
|
| 337 |
seg = ThinkingSegment()
|
|
|
|
| 418 |
# Task tool indicates subagent.
|
| 419 |
if name == "Task":
|
| 420 |
heading = self._task_heading_from_input(ev.get("input"))
|
| 421 |
+
seg = SubagentSegment(heading)
|
| 422 |
+
self._segments.append(seg)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
self._subagent_push(tool_id, seg)
|
| 424 |
return
|
| 425 |
|
|
|
|
| 476 |
|
| 477 |
if name == "Task":
|
| 478 |
heading = self._task_heading_from_input(ev.get("input"))
|
| 479 |
+
seg = SubagentSegment(heading)
|
| 480 |
+
self._segments.append(seg)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
self._subagent_push(tool_id, seg)
|
| 482 |
return
|
| 483 |
|
|
|
|
| 501 |
name = self._tool_name_by_id.get(tool_id)
|
| 502 |
|
| 503 |
# If this was the Task tool result, close subagent context.
|
| 504 |
+
if self._subagent_stack:
|
| 505 |
+
popped = self._subagent_pop(tool_id)
|
| 506 |
+
top = self._subagent_stack[-1] if self._subagent_stack else ""
|
| 507 |
+
looks_like_task_id = "task" in tool_id.lower()
|
| 508 |
+
# Some streams omit Task tool_use ids (synthetic stack ids), but include
|
| 509 |
+
# a real Task id on tool_result (e.g. "functions.Task:0"). Reconcile that.
|
| 510 |
+
if (
|
| 511 |
+
not popped
|
| 512 |
+
and tool_id
|
| 513 |
+
and top.startswith("__task_")
|
| 514 |
+
and (name in (None, "Task"))
|
| 515 |
+
and looks_like_task_id
|
| 516 |
+
):
|
| 517 |
+
self._subagent_pop("")
|
| 518 |
|
| 519 |
if not self._show_tool_results:
|
| 520 |
return
|
tests/test_transcript.py
CHANGED
|
@@ -107,6 +107,152 @@ def test_transcript_subagent_closes_on_whitespace_tool_ids():
|
|
| 107 |
assert "\n π€ *Subagent:* `Next`" not in out
|
| 108 |
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
def test_transcript_truncates_by_dropping_oldest_segments():
|
| 111 |
t = TranscriptBuffer()
|
| 112 |
|
|
|
|
| 107 |
assert "\n π€ *Subagent:* `Next`" not in out
|
| 108 |
|
| 109 |
|
| 110 |
+
def test_transcript_subagent_closes_on_task_result_id_suffix_match():
|
| 111 |
+
t = TranscriptBuffer()
|
| 112 |
+
t.apply(
|
| 113 |
+
{
|
| 114 |
+
"type": "tool_use",
|
| 115 |
+
"id": "task_1",
|
| 116 |
+
"name": "Task",
|
| 117 |
+
"input": {"description": "Outer"},
|
| 118 |
+
}
|
| 119 |
+
)
|
| 120 |
+
t.apply({"type": "tool_result", "tool_use_id": "task_1_result", "content": "done"})
|
| 121 |
+
t.apply(
|
| 122 |
+
{
|
| 123 |
+
"type": "tool_use",
|
| 124 |
+
"id": "task_2",
|
| 125 |
+
"name": "Task",
|
| 126 |
+
"input": {"description": "Next"},
|
| 127 |
+
}
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
out = t.render(_ctx(), limit_chars=3900, status=None)
|
| 131 |
+
assert out.count("Subagent:") == 2
|
| 132 |
+
assert "\n π€ *Subagent:* `Next`" not in out
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def test_transcript_unmatched_non_task_tool_result_does_not_pop_subagent():
|
| 136 |
+
t = TranscriptBuffer()
|
| 137 |
+
t.apply(
|
| 138 |
+
{
|
| 139 |
+
"type": "tool_use",
|
| 140 |
+
"id": "task_1",
|
| 141 |
+
"name": "Task",
|
| 142 |
+
"input": {"description": "Outer"},
|
| 143 |
+
}
|
| 144 |
+
)
|
| 145 |
+
t.apply({"type": "tool_result", "tool_use_id": "totally_unrelated", "content": "x"})
|
| 146 |
+
|
| 147 |
+
assert t._subagent_stack == ["task_1"]
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def test_transcript_sequential_tasks_mismatched_results_no_depth_drift():
|
| 151 |
+
t = TranscriptBuffer()
|
| 152 |
+
t.apply(
|
| 153 |
+
{
|
| 154 |
+
"type": "tool_use",
|
| 155 |
+
"id": "task_1",
|
| 156 |
+
"name": "Task",
|
| 157 |
+
"input": {"description": "A"},
|
| 158 |
+
}
|
| 159 |
+
)
|
| 160 |
+
t.apply({"type": "tool_result", "tool_use_id": "task_1_result", "content": "done"})
|
| 161 |
+
t.apply(
|
| 162 |
+
{
|
| 163 |
+
"type": "tool_use",
|
| 164 |
+
"id": "task_2",
|
| 165 |
+
"name": "Task",
|
| 166 |
+
"input": {"description": "B"},
|
| 167 |
+
}
|
| 168 |
+
)
|
| 169 |
+
t.apply({"type": "tool_result", "tool_use_id": "task_2_result", "content": "done"})
|
| 170 |
+
t.apply(
|
| 171 |
+
{
|
| 172 |
+
"type": "tool_use",
|
| 173 |
+
"id": "task_3",
|
| 174 |
+
"name": "Task",
|
| 175 |
+
"input": {"description": "C"},
|
| 176 |
+
}
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
out = t.render(_ctx(), limit_chars=3900, status=None)
|
| 180 |
+
assert "π€ *Subagent:* `A`\n π€ *Subagent:* `B`" not in out
|
| 181 |
+
assert "\n π€ *Subagent:* `C`" not in out
|
| 182 |
+
assert t._subagent_stack == ["task_3"]
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def test_transcript_synthetic_task_start_closes_on_functions_task_result_id():
|
| 186 |
+
t = TranscriptBuffer()
|
| 187 |
+
t.apply(
|
| 188 |
+
{
|
| 189 |
+
"type": "tool_use_start",
|
| 190 |
+
"index": 0,
|
| 191 |
+
"id": "",
|
| 192 |
+
"name": "Task",
|
| 193 |
+
"input": {"description": "Outer"},
|
| 194 |
+
}
|
| 195 |
+
)
|
| 196 |
+
t.apply({"type": "tool_result", "tool_use_id": "functions.Task:0", "content": "x"})
|
| 197 |
+
t.apply(
|
| 198 |
+
{
|
| 199 |
+
"type": "tool_use_start",
|
| 200 |
+
"index": 1,
|
| 201 |
+
"id": "",
|
| 202 |
+
"name": "Task",
|
| 203 |
+
"input": {"description": "Next"},
|
| 204 |
+
}
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
out = t.render(_ctx(), limit_chars=3900, status=None)
|
| 208 |
+
assert out.count("Subagent:") == 2
|
| 209 |
+
assert "\n π€ *Subagent:* `Next`" not in out
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def test_transcript_synthetic_task_not_closed_by_unknown_non_task_result_id():
|
| 213 |
+
t = TranscriptBuffer()
|
| 214 |
+
t.apply(
|
| 215 |
+
{
|
| 216 |
+
"type": "tool_use_start",
|
| 217 |
+
"index": 0,
|
| 218 |
+
"id": "",
|
| 219 |
+
"name": "Task",
|
| 220 |
+
"input": {"description": "Outer"},
|
| 221 |
+
}
|
| 222 |
+
)
|
| 223 |
+
t.apply({"type": "tool_result", "tool_use_id": "call_deadbeef", "content": "x"})
|
| 224 |
+
|
| 225 |
+
assert t._subagent_stack == ["__task_1"]
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def test_transcript_overlapping_tasks_are_flat_not_nested():
|
| 229 |
+
t = TranscriptBuffer()
|
| 230 |
+
t.apply(
|
| 231 |
+
{
|
| 232 |
+
"type": "tool_use",
|
| 233 |
+
"id": "task_a",
|
| 234 |
+
"name": "Task",
|
| 235 |
+
"input": {"description": "A"},
|
| 236 |
+
}
|
| 237 |
+
)
|
| 238 |
+
t.apply(
|
| 239 |
+
{
|
| 240 |
+
"type": "tool_use",
|
| 241 |
+
"id": "task_b",
|
| 242 |
+
"name": "Task",
|
| 243 |
+
"input": {"description": "B"},
|
| 244 |
+
}
|
| 245 |
+
)
|
| 246 |
+
t.apply({"type": "tool_result", "tool_use_id": "task_b", "content": "done"})
|
| 247 |
+
t.apply({"type": "tool_result", "tool_use_id": "task_a", "content": "done"})
|
| 248 |
+
|
| 249 |
+
out = t.render(_ctx(), limit_chars=3900, status=None)
|
| 250 |
+
assert "π€ *Subagent:* `A`" in out
|
| 251 |
+
assert "π€ *Subagent:* `B`" in out
|
| 252 |
+
assert out.find("π€ *Subagent:* `A`") < out.find("π€ *Subagent:* `B`")
|
| 253 |
+
assert "\n π€ *Subagent:* `B`" not in out
|
| 254 |
+
|
| 255 |
+
|
| 256 |
def test_transcript_truncates_by_dropping_oldest_segments():
|
| 257 |
t = TranscriptBuffer()
|
| 258 |
|