Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions src/core/hub.bend
Original file line number Diff line number Diff line change
Expand Up @@ -893,24 +893,41 @@ def Bg.started(agent: Bool, +s: M.State, +th: String, +now: Nat, +o: J.Json) ->
case False{}:
Nil{}

def Bg.ended(done: Bool, +s: M.State, +th: String, +now: Nat, +id: String) -> List<&2, M.Change>:
# a foreground shell Claude moved to the background when it ran past its
# timeout (task_updated sets is_backgrounded; the patch has no description)
def Bg.moved(bg: Bool, +s: M.State, +th: String, +now: Nat, +id: String) -> List<&2, M.Change>:
match bg:
case True{}:
Bg.put(s, th, now, M.Bg.add(M.Bg.of(s, th), id, "Background shell"))
case False{}:
Nil{}

def Bg.ended(done: Bool, bg: Bool, +s: M.State, +th: String, +now: Nat, +id: String) -> List<&2, M.Change>:
match done:
case True{}:
Bg.put(s, th, now, M.Bg.drop(M.Bg.of(s, th), id))
case False{}:
Nil{}
Bg.moved(bg, s, th, now, id)

# a task the agent left running: a subagent, a background shell or
# monitor, a workflow; not one a subagent runs for itself, and not a
# foreground shell (listed only if Claude moves it to the background,
# Bg.moved)
def Bg.listed(+o: J.Json) -> Bool:
Bool.and(Nat.is_lt(C.Codec.pos(["local_agent", "local_bash", "local_workflow", "remote_agent", "monitor_mcp", "monitor_ws", "in_process_teammate"], J.Json.get_str(o, "task_type", "")), 7n),
Bool.and(Bool.not(J.Json.get_bool(o, "owned_by_subagent", False{})), J.Json.get_bool(o, "is_backgrounded", True{})))

def Bg.system.k(i: Nat, +s: M.State, +th: String, +now: Nat, +o: J.Json) -> List<&2, M.Change>:
match i:
case 0n:
Bg.started(String.eq(J.Json.get_str(o, "task_type", ""), "local_agent"), s, th, now, o)
Bg.started(Bg.listed(o), s, th, now, o)
case 1n:
Bg.put(s, th, now, M.Bg.set(M.Bg.of(s, th), J.Json.get_str(o, "task_id", ""), J.Json.get_str(o, "description", "")))
case 2n:
Bg.put(s, th, now, M.Bg.drop(M.Bg.of(s, th), J.Json.get_str(o, "task_id", "")))
case 3n:
Bg.ended(Nat.is_lt(C.Codec.pos(["completed", "failed", "killed", "stopped", "cancelled"], J.Json.get_str(J.Json.get_obj(o, "patch"), "status", "")), 5n),
s, th, now, J.Json.get_str(o, "task_id", ""))
J.Json.get_bool(J.Json.get_obj(o, "patch"), "is_backgrounded", False{}), s, th, now, J.Json.get_str(o, "task_id", ""))
case _:
Nil{}

Expand Down
8 changes: 4 additions & 4 deletions src/core/model.bend
Original file line number Diff line number Diff line change
Expand Up @@ -2101,10 +2101,10 @@ def Phase.label(p: Phase) -> String:

# Subagents
# ---------
# The subagents an agent has at work (Claude's task_started, task_progress
# and task_notification lines), kept as the setting "bg.<thread>": one
# "id\tdoing" line each. They can run on after the agent's own turn ends,
# so the thread still shows them working.
# The subagents, background shells and monitors an agent has at work
# (Claude's task_started, task_progress and task_notification lines), kept
# as the setting "bg.<thread>": one "id\tdoing" line each. They can run on
# after the agent's own turn ends, so the thread shows it monitoring them.

def Bg.key(+th: String) -> String:
"bg." ++ th
Expand Down
28 changes: 26 additions & 2 deletions test/subagents_test.bend
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import ../src/core/model.bend as M
import ../src/core/hub.bend as H
import ../src/core/client.bend as U

# Claude's subagents: a background one started, said what it does, and
# Claude's subagents and background shells: a background one started, said what it does, and
# ended (lines as Claude Code 2.1 writes them); the thread waits on it
# after its own turn ends, and an agent that exits leaves none at work.

Expand Down Expand Up @@ -38,6 +38,21 @@ def progressed() -> List<&2, M.Change>:
def ended() -> List<&2, M.Change>:
after(progressed(), 105n, "{\"type\":\"system\",\"subtype\":\"task_notification\",\"task_id\":\"a847\",\"tool_use_id\":\"toolu_1\",\"status\":\"completed\",\"session_id\":\"s\"}")

# a shell the agent put in the background (or Claude moved there when it
# ran past its timeout)
def shell() -> List<&2, M.Change>:
after(made(), 102n, "{\"type\":\"system\",\"subtype\":\"task_started\",\"task_id\":\"b3\",\"description\":\"scripts/check.sh\",\"task_type\":\"local_bash\",\"is_backgrounded\":true}")

def shell.done() -> List<&2, M.Change>:
List.append(&2, M.Change, shell(), [M.TurnChanged{"t100-1", M.Completed{}, 103n}])

# a foreground shell (Claude names it once it has run 30 s) that ran past
# its timeout and was moved to the background (lines as Claude Code 2.1.285
# writes them)
def moved() -> List<&2, M.Change>:
after(after(made(), 102n, "{\"type\":\"system\",\"subtype\":\"task_started\",\"task_id\":\"b5\",\"task_type\":\"local_bash\",\"is_backgrounded\":false,\"description\":\"Run checks\"}"),
103n, "{\"type\":\"system\",\"subtype\":\"task_updated\",\"task_id\":\"b5\",\"patch\":{\"is_backgrounded\":true}}")

def status(+es: List<&2, M.Change>) -> String:
M.Status.name(U.Side.status(state(es), H.Hub.thread(state(es), "t100-1")))

Expand All @@ -63,7 +78,16 @@ def main() -> IO(Unit):
C.Check.str("progress", doing(progressed()), "Running Sleep for 20 seconds")
C.Check.str("progress again writes nothing", Nat.show(List.length(&2, M.Change, progressed())), Nat.show(List.length(&2, M.Change,
after(progressed(), 106n, "{\"type\":\"system\",\"subtype\":\"task_progress\",\"task_id\":\"a847\",\"description\":\"Running Sleep for 20 seconds\",\"session_id\":\"s\"}"))))
C.Check.str("a subagent's bash is not listed", doing(after(done(), 104n, "{\"type\":\"system\",\"subtype\":\"task_started\",\"task_id\":\"b1\",\"description\":\"Sleep\",\"task_type\":\"local_bash\"}")), "Run bash command and report output")
C.Check.str("a subagent's bash is not listed", doing(after(done(), 104n, "{\"type\":\"system\",\"subtype\":\"task_started\",\"task_id\":\"b1\",\"description\":\"Sleep\",\"task_type\":\"local_bash\",\"owned_by_subagent\":true}")), "Run bash command and report output")
C.Check.str("a foreground shell is not listed", doing(after(made(), 102n, "{\"type\":\"system\",\"subtype\":\"task_started\",\"task_id\":\"b2\",\"description\":\"sleep 4\",\"task_type\":\"local_bash\",\"is_backgrounded\":false}")), "")
C.Check.str("a background shell is listed", doing(shell()), "scripts/check.sh")
C.Check.str("the turn over, it monitors the shell", status(shell.done()), "waiting")
C.Check.str("monitoring phase", phase(shell.done()), "Monitoring 1 task")
C.Check.str("the shell ends: ready", status(after(shell.done(), 105n, "{\"type\":\"system\",\"subtype\":\"task_notification\",\"task_id\":\"b3\",\"status\":\"completed\"}")), "ready")
C.Check.str("a shell moved to the background past its timeout is listed", doing(moved()), "Background shell")
C.Check.str("moved, the turn over: monitoring", phase(List.append(&2, M.Change, moved(), [M.TurnChanged{"t100-1", M.Completed{}, 104n}])), "Monitoring 1 task")
C.Check.str("moved, then done", doing(after(moved(), 105n, "{\"type\":\"system\",\"subtype\":\"task_updated\",\"task_id\":\"b5\",\"patch\":{\"status\":\"completed\",\"end_time\":1}}")), "")
C.Check.str("a monitor is listed", doing(after(made(), 102n, "{\"type\":\"system\",\"subtype\":\"task_started\",\"task_id\":\"b4\",\"description\":\"CI\",\"task_type\":\"monitor_mcp\"}")), "CI")
C.Check.str("ended", doing(ended()), "")
C.Check.str("ready after", status(ended()), "ready")
C.Check.str("updated to completed ends it", doing(after(progressed(), 105n, "{\"type\":\"system\",\"subtype\":\"task_updated\",\"task_id\":\"a847\",\"patch\":{\"status\":\"completed\"}}")), "")
Expand Down
Loading