Skip to content

Commit 445df69

Browse files
committed
feat: live sub-agent log streaming in WebUI
- loop: add ToolEventHandler callback, fires tool_call + tool_result live - odek: wire ToolEventHandler from Config through to engine - subagent: add --stream flag, emits NDJSON tool events to stdout - subagent_tool: read stdout line-by-line, forward via OnSubagentLog - serve: wire ToolEventHandler to WebSocket, subagent log forwarding - WebUI: handle subagent_log events, append to running sub-agent cards
1 parent 30f5cbe commit 445df69

6 files changed

Lines changed: 186 additions & 30 deletions

File tree

cmd/odek/serve.go

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,34 @@ func newServeAgent(resolved config.ResolvedConfig, system string, sendFn func(v
178178
resolved.Dangerous.Approver = approver
179179

180180
tools := builtinTools(resolved.Dangerous, sm, approver, resolved.MaxConcurrency)
181+
182+
// Find the delegateTasksTool to wire up sub-agent log streaming
183+
var subagentTool *delegateTasksTool
184+
for _, t := range tools {
185+
if dt, ok := t.(*delegateTasksTool); ok {
186+
subagentTool = dt
187+
break
188+
}
189+
}
190+
if subagentTool != nil {
191+
subagentTool.OnSubagentLog = func(taskIdx int, line string) {
192+
var event struct {
193+
Type string `json:"type"`
194+
Name string `json:"name,omitempty"`
195+
Data string `json:"data,omitempty"`
196+
}
197+
if err := json.Unmarshal([]byte(line), &event); err != nil {
198+
return
199+
}
200+
sendFn(map[string]any{
201+
"type": "subagent_log",
202+
"task_idx": taskIdx,
203+
"name": event.Name,
204+
"event": event.Type,
205+
"data": event.Data,
206+
})
207+
}
208+
}
181209
var sandboxCleanup func() error
182210

183211
// MCP server tools
@@ -224,6 +252,13 @@ func newServeAgent(resolved config.ResolvedConfig, system string, sendFn func(v
224252
Skills: &resolved.Skills,
225253
SkillManager: sm,
226254
MemoryConfig: resolved.Memory,
255+
ToolEventHandler: func(event, name, data string) {
256+
sendFn(map[string]any{
257+
"type": event,
258+
"name": name,
259+
"data": data,
260+
})
261+
},
227262
})
228263
if err != nil {
229264
return nil, nil, nil, nil, err
@@ -437,26 +472,16 @@ func handlePrompt(
437472
newMsgs = newMsgs[origLen:]
438473
}
439474

440-
// Stream the assistant's response
475+
// Stream the assistant's response (tool events are streamed live via ToolEventHandler)
441476
for _, msg := range newMsgs {
442477
if msg.Role == "assistant" {
443478
if msg.Content != "" {
444479
writeWSJSON(conn, map[string]any{"type": "token", "content": msg.Content})
445480
}
446-
for _, tc := range msg.ToolCalls {
447-
writeWSJSON(conn, map[string]any{
448-
"type": "tool_call",
449-
"name": tc.Function.Name,
450-
"command": tc.Function.Arguments,
451-
})
452-
}
481+
// tool_call events are streamed live via ToolEventHandler — skip here
453482
}
454483
if msg.Role == "tool" {
455-
writeWSJSON(conn, map[string]any{
456-
"type": "tool_result",
457-
"name": msg.Name,
458-
"output": shorten(msg.Content, 500),
459-
})
484+
// tool_result events are streamed live via ToolEventHandler — skip here
460485
}
461486
}
462487

cmd/odek/subagent.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ func subagentCmd(args []string) error {
173173
timeout int
174174
maxIter int
175175
quiet bool
176+
stream bool
176177
parentSession string
177178
}
178179

@@ -207,6 +208,8 @@ func subagentCmd(args []string) error {
207208
}
208209
case "--quiet":
209210
cfg.quiet = true
211+
case "--stream":
212+
cfg.stream = true
210213
case "--parent-session":
211214
i++
212215
if i < len(args) {
@@ -335,7 +338,8 @@ func subagentCmd(args []string) error {
335338
// Create agent — silent renderer for stderr
336339
rend := render.New(os.Stderr, !cfg.quiet)
337340

338-
agent, err := odek.New(odek.Config{
341+
// Build agent config, optionally with streaming
342+
aCfg := odek.Config{
339343
Model: resolved.Model,
340344
BaseURL: resolved.BaseURL,
341345
APIKey: resolved.APIKey,
@@ -349,7 +353,19 @@ func subagentCmd(args []string) error {
349353
Skills: &resolved.Skills,
350354
SkillManager: sm,
351355
MemoryConfig: resolved.Memory,
352-
})
356+
}
357+
if cfg.stream {
358+
aCfg.ToolEventHandler = func(event, name, data string) {
359+
line, _ := json.Marshal(map[string]string{
360+
"type": event,
361+
"name": name,
362+
"data": data,
363+
})
364+
os.Stdout.Write(line)
365+
os.Stdout.Write([]byte("\n"))
366+
}
367+
}
368+
agent, err := odek.New(aCfg)
353369
if err != nil {
354370
return fmt.Errorf("create agent: %w", err)
355371
}

cmd/odek/subagent_tool.go

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"bufio"
45
"context"
56
"encoding/json"
67
"fmt"
@@ -24,6 +25,11 @@ type delegateTasksTool struct {
2425
maxConcurrency int
2526
odekPath string // path to the odek binary
2627
timeout time.Duration
28+
29+
// OnSubagentLog, if set, is called with each NDJSON progress line
30+
// emitted by a sub-agent. taskIdx is the index within the current
31+
// batch. Used by the WebUI for live log streaming.
32+
OnSubagentLog func(taskIdx int, line string)
2733
}
2834

2935
func (t *delegateTasksTool) Name() string { return "delegate_tasks" }
@@ -112,7 +118,7 @@ func (t *delegateTasksTool) Call(args string) (string, error) {
112118
sem <- struct{}{}
113119
go func(i int, goal, ctx, sys string) {
114120
defer func() { <-sem }()
115-
r := t.runTask(goal, ctx, sys)
121+
r := t.runTask(i, goal, ctx, sys)
116122
mu.Lock()
117123
results[i] = r
118124
mu.Unlock()
@@ -135,7 +141,7 @@ func (t *delegateTasksTool) Call(args string) (string, error) {
135141
return buf.String(), nil
136142
}
137143

138-
func (t *delegateTasksTool) runTask(goal, taskContext, system string) string {
144+
func (t *delegateTasksTool) runTask(taskIdx int, goal, taskContext, system string) string {
139145
ctx, cancel := context.WithTimeout(context.Background(), t.timeout)
140146
defer cancel()
141147

@@ -163,6 +169,7 @@ func (t *delegateTasksTool) runTask(goal, taskContext, system string) string {
163169
"subagent",
164170
"--task", taskPath,
165171
"--quiet",
172+
"--stream",
166173
)
167174

168175
stdout, err := cmd.StdoutPipe()
@@ -178,30 +185,65 @@ func (t *delegateTasksTool) runTask(goal, taskContext, system string) string {
178185
return fmt.Sprintf(`{"error":"start: %v"}`, err)
179186
}
180187

188+
// Read stdout line-by-line — NDJSON progress lines followed by final JSON result
181189
var result map[string]any
182-
if err := json.NewDecoder(stdout).Decode(&result); err != nil {
183-
// Wait for process to finish, then check for timeout
184-
cmd.Wait()
185-
if ctx.Err() != nil {
186-
return fmt.Sprintf(`{"error":"timeout after %v"}`, t.timeout)
190+
var lastLine string
191+
scanner := bufio.NewScanner(stdout)
192+
for scanner.Scan() {
193+
line := scanner.Text()
194+
lastLine = line
195+
196+
// Check if this is a progress line (NDJSON with "type":"tool_call" or "type":"tool_result")
197+
var event struct {
198+
Type string `json:"type"`
199+
}
200+
if err := json.Unmarshal([]byte(line), &event); err == nil {
201+
if event.Type == "tool_call" || event.Type == "tool_result" {
202+
if t.OnSubagentLog != nil {
203+
t.OnSubagentLog(taskIdx, line)
204+
}
205+
continue
206+
}
207+
}
208+
209+
// Not a progress line — parse as result JSON
210+
var r map[string]any
211+
if err := json.Unmarshal([]byte(line), &r); err == nil {
212+
result = r
187213
}
188-
return fmt.Sprintf(`{"error":"parse result: %v"}`, err)
189214
}
215+
scannerErr := scanner.Err()
190216

191217
if err := cmd.Wait(); err != nil {
192-
// Process exited with error — result may still be valid JSON
218+
// Process exited with error — result may still be valid
193219
if result != nil {
194220
summary, _ := json.MarshalIndent(result, "", " ")
195221
return string(summary)
196222
}
197223
if ctx.Err() != nil {
198224
return fmt.Sprintf(`{"error":"timeout after %v"}`, t.timeout)
199225
}
226+
if scannerErr != nil {
227+
return fmt.Sprintf(`{"error":"read stdout: %v"}`, scannerErr)
228+
}
200229
return fmt.Sprintf(`{"error":"exit: %v"}`, err)
201230
}
202231

203-
summary, _ := json.MarshalIndent(result, "", " ")
204-
return string(summary)
232+
if result != nil {
233+
summary, _ := json.MarshalIndent(result, "", " ")
234+
return string(summary)
235+
}
236+
237+
// Last resort: try parsing the last line as JSON
238+
if lastLine != "" {
239+
var r map[string]any
240+
if err := json.Unmarshal([]byte(lastLine), &r); err == nil {
241+
summary, _ := json.MarshalIndent(r, "", " ")
242+
return string(summary)
243+
}
244+
}
245+
246+
return fmt.Sprintf(`{"error":"no result from sub-agent"}`)
205247
}
206248

207249
// Ensure delegateTasksTool implements odek.Tool

cmd/odek/ui/index.html

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,8 @@
393393
.subagent-card .sa-details.open { display: block; animation: fadeIn 0.2s ease-out; }
394394
.subagent-card .sa-meta { font-size: 11px; color: var(--text-tertiary); font-family: var(--font-mono); display: flex; gap: 12px; margin-bottom: 6px; }
395395
.subagent-card .sa-summary { font-size: 12px; color: var(--text-secondary); line-height: 1.5; white-space: pre-wrap; }
396+
.subagent-card .sa-log { margin-bottom: 6px; }
397+
.subagent-card .sa-log .log-line { font-size: 11px; color: var(--text-tertiary); font-family: var(--font-mono); line-height: 1.4; padding: 1px 0; }
396398
.subagent-card .sa-files { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
397399
.subagent-card .sa-files .file-chip { font-size: 11px; padding: 1px 6px; }
398400

@@ -1366,17 +1368,21 @@ <h3>Delete session?</h3>
13661368
streamFlush();
13671369
endThinking();
13681370
if (event.name === 'delegate_tasks') {
1369-
addSubagentGroup(event.command);
1371+
addSubagentGroup(event.data);
13701372
} else {
1371-
addToolCall(event.name, event.command);
1373+
addToolCall(event.name, event.data);
13721374
}
13731375
break;
13741376

13751377
case 'tool_result':
13761378
if (event.name === 'delegate_tasks' && subagentGroup) {
1377-
completeSubagents(event.output);
1379+
completeSubagents(event.data);
13781380
}
1379-
addToolResult(event.name, event.output);
1381+
addToolResult(event.name, event.data);
1382+
break;
1383+
1384+
case 'subagent_log':
1385+
appendSubagentLog(event.task_idx, event);
13801386
break;
13811387

13821388
case 'done':
@@ -1767,6 +1773,47 @@ <h3>Delete session?</h3>
17671773
el.classList.toggle('open');
17681774
};
17691775

1776+
function appendSubagentLog(taskIdx, event) {
1777+
if (!subagentGroup) return;
1778+
const cards = subagentGroup.querySelectorAll('.subagent-card');
1779+
const card = cards[taskIdx];
1780+
if (!card) return;
1781+
1782+
// Ensure details are open
1783+
const details = card.querySelector('.sa-details');
1784+
if (!details) return;
1785+
const summaryEl = details.querySelector('.sa-summary');
1786+
1787+
// Format the log line
1788+
let text = '';
1789+
if (event.event === 'tool_call') {
1790+
text = '🔧 ' + event.name + (event.data ? '(' + truncateStr(event.data, 60) + ')' : '');
1791+
} else if (event.event === 'tool_result') {
1792+
text = '📄 ' + truncateStr(event.data || '', 100);
1793+
}
1794+
if (!text) return;
1795+
1796+
// Append to existing summary content (or create a log container)
1797+
let logContainer = card.querySelector('.sa-log');
1798+
if (!logContainer) {
1799+
logContainer = document.createElement('div');
1800+
logContainer.className = 'sa-log';
1801+
details.insertBefore(logContainer, summaryEl);
1802+
}
1803+
const lineEl = document.createElement('div');
1804+
lineEl.className = 'log-line';
1805+
lineEl.textContent = text;
1806+
logContainer.appendChild(lineEl);
1807+
1808+
details.classList.add('open');
1809+
scrollBottom();
1810+
}
1811+
1812+
function truncateStr(s, n) {
1813+
if (!s) return '';
1814+
return s.length > n ? s.substring(0, n) + '…' : s;
1815+
}
1816+
17701817
// ── Approval ──
17711818
let approvalId = null;
17721819

internal/loop/loop.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ import (
1818
// (formatted skill content) to inject, or empty string if no skills match.
1919
type SkillLoader func(userInput string) string
2020

21+
// ToolEventHandler is an optional callback invoked for each tool execution
22+
// during the agent loop — fires before (tool_call) and after (tool_result)
23+
// each tool invocation. Used by the WebUI for live streaming of tool events.
24+
type ToolEventHandler func(event string, name string, data string)
25+
2126
// Engine runs the agent loop: observe → think → act → repeat.
2227
type Engine struct {
2328
client *llm.Client
@@ -29,6 +34,8 @@ type Engine struct {
2934
skillLoader SkillLoader // optional: loads matching skills
3035
lastSkillMsg string // last user message that triggered skill loading (dedup)
3136

37+
toolEventHandler ToolEventHandler // optional: fires during tool execution
38+
3239
// Token accounting — accumulated across all iterations of the most recent run.
3340
// Reset on each Run/RunWithMessages call and read by callers (e.g. WebUI).
3441
TotalInputTokens int
@@ -52,6 +59,9 @@ func New(client *llm.Client, registry *tool.Registry, maxIterations int, systemM
5259
// SetSkillLoader sets the optional skill loader callback.
5360
func (e *Engine) SetSkillLoader(sl SkillLoader) { e.skillLoader = sl }
5461

62+
// SetToolEventHandler sets the optional tool event callback for live streaming.
63+
func (e *Engine) SetToolEventHandler(cb ToolEventHandler) { e.toolEventHandler = cb }
64+
5565
// ── Token Estimation ─────────────────────────────────────────────────
5666
//
5767
// Zero-dependency heuristic: 1 token ≈ 4 chars for English text.
@@ -278,6 +288,9 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
278288
if e.renderer != nil {
279289
e.renderer.ToolCall(tc.Function.Name, tc.Function.Arguments)
280290
}
291+
if e.toolEventHandler != nil {
292+
e.toolEventHandler("tool_call", tc.Function.Name, tc.Function.Arguments)
293+
}
281294

282295
t := e.registry.Get(tc.Function.Name)
283296
output := fmt.Sprintf("error: tool %q not found", tc.Function.Name)
@@ -293,6 +306,9 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
293306
if e.renderer != nil {
294307
e.renderer.ToolResult(output)
295308
}
309+
if e.toolEventHandler != nil {
310+
e.toolEventHandler("tool_result", tc.Function.Name, output)
311+
}
296312

297313
// Wrap tool output in clear delimiters so the model treats
298314
// it as DATA, not as instructions. Even if the output

0 commit comments

Comments
 (0)