feat(api): structured run events via /v1/runs SSE endpoint

Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).

Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.

Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.

Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.

Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
This commit is contained in:
Mibayy
2026-04-05 11:52:46 -07:00
committed by Teknium
parent e167ad8f61
commit cc2b56b26a
11 changed files with 337 additions and 44 deletions

View File

@@ -52,7 +52,7 @@ class TestToolProgressCallback:
future.result.return_value = None
mock_rcts.return_value = future
cb("terminal", "$ ls -la", {"command": "ls -la"})
cb("tool.started", "terminal", "$ ls -la", {"command": "ls -la"})
# Should have tracked the tool call ID
assert "terminal" in tool_call_ids
@@ -75,7 +75,7 @@ class TestToolProgressCallback:
future.result.return_value = None
mock_rcts.return_value = future
cb("read_file", "Reading /etc/hosts", '{"path": "/etc/hosts"}')
cb("tool.started", "read_file", "Reading /etc/hosts", '{"path": "/etc/hosts"}')
assert "read_file" in tool_call_ids
@@ -91,7 +91,7 @@ class TestToolProgressCallback:
future.result.return_value = None
mock_rcts.return_value = future
cb("terminal", "$ echo hi", None)
cb("tool.started", "terminal", "$ echo hi", None)
assert "terminal" in tool_call_ids
@@ -108,8 +108,8 @@ class TestToolProgressCallback:
future.result.return_value = None
mock_rcts.return_value = future
progress_cb("terminal", "$ ls", {"command": "ls"})
progress_cb("terminal", "$ pwd", {"command": "pwd"})
progress_cb("tool.started", "terminal", "$ ls", {"command": "ls"})
progress_cb("tool.started", "terminal", "$ pwd", {"command": "pwd"})
assert len(tool_call_ids["terminal"]) == 2
step_cb(1, [{"name": "terminal", "result": "ok-1"}])

View File

@@ -130,7 +130,7 @@ class TestMcpRegistrationE2E:
# 1) Agent fires tool_progress_callback (ToolCallStart)
if agent.tool_progress_callback:
agent.tool_progress_callback(
"terminal", "$ echo hello", {"command": "echo hello"}
"tool.started", "terminal", "$ echo hello", {"command": "echo hello"}
)
# 2) Agent fires step_callback with tool results (ToolCallUpdate)
@@ -197,8 +197,8 @@ class TestMcpRegistrationE2E:
agent = state.agent
# Fire two tool calls
if agent.tool_progress_callback:
agent.tool_progress_callback("read_file", "read: /etc/hosts", {"path": "/etc/hosts"})
agent.tool_progress_callback("web_search", "web search: test", {"query": "test"})
agent.tool_progress_callback("tool.started", "read_file", "read: /etc/hosts", {"path": "/etc/hosts"})
agent.tool_progress_callback("tool.started", "web_search", "web search: test", {"query": "test"})
if agent.step_callback:
agent.step_callback(1, [

View File

@@ -96,7 +96,7 @@ class TestBuildChildProgressCallback:
cb = _build_child_progress_callback(0, parent)
assert cb is not None
cb("web_search", "quantum computing")
cb("tool.started", "web_search", "quantum computing", {})
output = buf.getvalue()
assert "web_search" in output
assert "quantum computing" in output
@@ -131,11 +131,11 @@ class TestBuildChildProgressCallback:
# Send 4 tool calls — shouldn't flush yet (BATCH_SIZE = 5)
for i in range(4):
cb(f"tool_{i}", f"arg_{i}")
cb("tool.started", f"tool_{i}", f"arg_{i}", {})
parent_cb.assert_not_called()
# 5th call should trigger flush
cb("tool_4", "arg_4")
cb("tool.started", "tool_4", "arg_4", {})
parent_cb.assert_called_once()
call_args = parent_cb.call_args
assert "tool_0" in call_args[0][1]
@@ -207,7 +207,7 @@ class TestBuildChildProgressCallback:
parent.tool_progress_callback = None
cb = _build_child_progress_callback(0, parent, task_count=1)
cb("web_search", "test")
cb("tool.started", "web_search", "test", {})
output = buf.getvalue()
assert "[" not in output
@@ -330,9 +330,9 @@ class TestBatchFlush:
cb = _build_child_progress_callback(0, parent)
# Send 3 tools (below batch size of 5)
cb("web_search", "query1")
cb("read_file", "file.txt")
cb("write_file", "out.txt")
cb("tool.started", "web_search", "query1", {})
cb("tool.started", "read_file", "file.txt", {})
cb("tool.started", "write_file", "out.txt", {})
parent_cb.assert_not_called()
# Flush should send the remaining 3
@@ -365,7 +365,7 @@ class TestBatchFlush:
parent.tool_progress_callback = None
cb = _build_child_progress_callback(0, parent)
cb("web_search", "test")
cb("tool.started", "web_search", "test", {})
cb._flush() # Should not crash

View File

@@ -60,9 +60,9 @@ class FakeAgent:
self.tools = []
def run_conversation(self, message, conversation_history=None, task_id=None):
self.tool_progress_callback("terminal", "pwd")
self.tool_progress_callback("tool.started", "terminal", "pwd", {})
time.sleep(0.35)
self.tool_progress_callback("browser_navigate", "https://example.com")
self.tool_progress_callback("tool.started", "browser_navigate", "https://example.com", {})
time.sleep(0.35)
return {
"final_response": "done",