LastNoob commited on
Commit
90a0517
Β·
1 Parent(s): 724e6f7

removed support for nested subagent

Browse files
Files changed (2) hide show
  1. messaging/transcript.py +47 -55
  2. 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, *, indent_level: int = 0) -> None:
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
- seg = ToolCallSegment(tool_use_id, name, indent_level=self.indent_level + 1)
162
- self.current_tool = seg
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
- prefix = " " * self.indent_level
171
- inner_prefix = " " * (self.indent_level + 1)
172
 
173
  lines: List[str] = [
174
- f"{prefix}πŸ€– {ctx.bold('Subagent:')} {ctx.code_inline(self.description)}"
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) -> None:
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] == tool_id:
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
- try:
314
- idx = (
315
- len(self._subagent_stack)
316
- - 1
317
- - self._subagent_stack[::-1].index(tool_id)
318
- )
319
- except ValueError:
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, indent_level=self._subagent_depth())
432
- parent = self._subagent_current()
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, indent_level=self._subagent_depth())
494
- parent = self._subagent_current()
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 and (
523
- not tool_id or self._subagent_stack[-1] == tool_id
524
- ):
525
- self._subagent_pop(tool_id)
 
 
 
 
 
 
 
 
 
 
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