Skip to content

Commit cc48823

Browse files
committed
debugger: add events stream; rename listen → wait-for-reply, history → chat-history
`events` prints the session as a flat, timestamped, one-line-per-event stream (messages, tool calls, handoffs, config changes, errors, agent state transitions), replaying the most recent events by default and streaming new ones with --follow; --logs adds agent log lines and --json emits NDJSON so another program can consume the stream live. It observes through a separate tap, so it never affects what `say` and `wait-for-reply` see. Events now carry a `time` field (arrival time for live events, the SDK's created_at for chat-history items), and agent state transitions are reported on the stream as `state` events. `listen` is renamed `wait-for-reply` and `history` is renamed `chat-history` (the `transcript` alias stays).
1 parent 4661bf1 commit cc48823

8 files changed

Lines changed: 408 additions & 73 deletions

File tree

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -491,8 +491,9 @@ A typical session, run from the agent project directory (`lk agent dbg` is short
491491
lk agent debugger start # starts the agent, prints its opening message if it has one
492492
lk agent debugger say "Hi, what can you do?" # prints tool calls (with arguments and results), handoffs, and the reply
493493
lk agent debugger say "Book a table for two tonight"
494-
lk agent debugger listen --timeout 15s # wait for the agent to speak unprompted (a timer, a follow-up after silence)
495-
lk agent debugger history # the whole conversation so far
494+
lk agent debugger wait-for-reply --timeout 15s # wait for the agent to speak unprompted (a timer, a follow-up after silence)
495+
lk agent debugger chat-history # the whole conversation so far
496+
lk agent debugger events --follow # live, one-line-per-event stream of everything the session does
496497
lk agent debugger logs --last 40 # the agent process's logs (tracebacks, warnings)
497498
lk agent debugger status # active agent, its tools, state, log file
498499
lk agent debugger stop --transcript # closing summary, plus the whole conversation
@@ -504,10 +505,11 @@ Useful options:
504505
505506
- `say --logs` shows the agent's log lines beneath the step they belong to, so a tool's traceback appears right under the sanitized error the user would hear.
506507
- `say --timeout 30s` bounds how long to wait for the reply (default 2 minutes); the exit code is non-zero if the turn failed or timed out.
507-
- `--json` on any command prints machine-readable output. Each turn is a document with `text`, `reply`, `duration_ms`, and an `events` list of `message`, `tool_call`, `handoff`, `config`, `error`, and `log` entries.
508+
- `--json` on any command prints machine-readable output. Each turn is a document with `text`, `reply`, `duration_ms`, and an `events` list of `message`, `tool_call`, `handoff`, `config`, `error`, `log`, and (on the events stream) `state` entries, each with a `time`.
508509
- `--metrics` adds per-turn latency metrics (time to first token, end-to-end).
509510
- `restart` relaunches the agent with a fresh conversation after the code changes.
510-
- `listen --timeout 15s` waits for the agent to speak unprompted, for example after a tool set a timer, and prints whatever it says.
511+
- `wait-for-reply --timeout 15s` waits for the agent to speak unprompted, for example after a tool set a timer, and prints whatever it says.
512+
- `events` prints the session as a flat, timestamped event stream (messages, tool calls, handoffs, state changes); `--follow` keeps streaming, `--logs` adds agent log lines, and `--json` emits NDJSON for piping into other tools.
511513
- `--port` runs several sessions side by side (one agent per port).
512514
- A session stops itself after 30 minutes without commands so a forgotten one doesn't linger; `start --idle-timeout` changes that (0 disables it).
513515

autocomplete/fish_autocomplete

Lines changed: 56 additions & 40 deletions
Large diffs are not rendered by default.

cmd/lk/debugger.go

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,10 @@ Typical flow, run from the agent project directory:
117117
lk agent debugger start # starts the agent, prints its greeting (if any)
118118
lk agent debugger say "Hi, what can you do?"
119119
lk agent debugger say "Book me a table for two tonight"
120-
lk agent debugger listen --timeout 15s # wait for the agent to speak unprompted (timers, follow-ups)
120+
lk agent debugger wait-for-reply --timeout 15s # wait for unprompted speech (timers, follow-ups)
121121
lk agent debugger logs --last 40 # agent process logs (tracebacks, warnings)
122-
lk agent debugger history # full transcript so far
122+
lk agent debugger chat-history # full transcript so far
123+
lk agent debugger events --follow # live one-line stream of every session event
123124
lk agent debugger stop --transcript # closing summary, plus the conversation
124125
125126
The agent is found the same way as for "lk agent console": the project in the
@@ -192,7 +193,7 @@ be piped on stdin:
192193
Action: runSessionSay,
193194
},
194195
{
195-
Name: "listen",
196+
Name: "wait-for-reply",
196197
Usage: "Wait for the agent to say something on its own (a greeting, timer, or follow-up) without sending a turn",
197198
Description: `Prints agent output that no turn asked for. Anything already waiting is printed
198199
immediately; otherwise it waits up to --timeout for the agent to start
@@ -214,7 +215,7 @@ did not.`,
214215
Action: runSessionListen,
215216
},
216217
{
217-
Name: "history",
218+
Name: "chat-history",
218219
Aliases: []string{"transcript"},
219220
Usage: "Print the conversation so far, as the agent recorded it",
220221
Description: `Fetches the agent's own chat history, so it reflects exactly what the LLM has
@@ -223,6 +224,28 @@ instruction/tool changes. Works while a turn is in progress.`,
223224
Flags: []cli.Flag{sessionPortFlag, jsonFlag, sessionMetricsFlag},
224225
Action: runSessionHistory,
225226
},
227+
{
228+
Name: "events",
229+
Usage: "Print the session's event stream, one line per event; --follow keeps streaming",
230+
Description: `Shows what happened in the session as a flat, timestamped stream: user and
231+
agent messages, tool calls with arguments and results, handoffs, config
232+
changes, errors, and agent state transitions, one line each. It observes
233+
without taking part, so it works alongside "say" from another shell, a person
234+
on "lk agent console", or a script driving the session.
235+
236+
By default the most recent events are printed and the command exits. With
237+
--follow it keeps printing new events until interrupted, and --logs adds the
238+
agent's log lines. --json emits one JSON object per line (NDJSON), suitable
239+
for piping into jq or another program in real time.`,
240+
Flags: []cli.Flag{
241+
sessionPortFlag,
242+
jsonFlag,
243+
&cli.IntFlag{Name: "last", Aliases: []string{"n"}, Value: 50, Usage: "How many recent events to print first (0 for all kept, up to 500)"},
244+
&cli.BoolFlag{Name: "follow", Aliases: []string{"f"}, Usage: "Keep streaming new events until interrupted"},
245+
&cli.BoolFlag{Name: "logs", Usage: "Include the agent's log lines in the stream"},
246+
},
247+
Action: runSessionEvents,
248+
},
226249
{
227250
Name: "status",
228251
Usage: "Show whether a session is running, which agent is active, and its tools",
@@ -559,7 +582,7 @@ func runSessionSay(ctx context.Context, cmd *cli.Command) error {
559582
return nil
560583
}
561584

562-
// listenJSON is the --json document `listen` prints.
585+
// listenJSON is the --json document `wait-for-reply` prints.
563586
type listenJSON struct {
564587
Events []turnEvent `json:"events"`
565588
Reply string `json:"reply"`
@@ -582,7 +605,7 @@ func runSessionListen(ctx context.Context, cmd *cli.Command) error {
582605
conn.Close() // ctrl-C stops waiting cleanly
583606
}()
584607

585-
if err := writeControlFrame(conn, controlRequest{Cmd: "listen", TimeoutMs: timeout.Milliseconds()}); err != nil {
608+
if err := writeControlFrame(conn, controlRequest{Cmd: "wait", TimeoutMs: timeout.Milliseconds()}); err != nil {
586609
return err
587610
}
588611

@@ -594,7 +617,7 @@ func runSessionListen(ctx context.Context, cmd *cli.Command) error {
594617
return
595618
}
596619
e := *r.Event
597-
e.Earlier = false // everything listen reports is, by definition, unprompted
620+
e.Earlier = false // everything wait-for-reply reports is, by definition, unprompted
598621
if asJSON {
599622
doc.Events = append(doc.Events, e)
600623
return
@@ -624,8 +647,50 @@ func runSessionListen(ctx context.Context, cmd *cli.Command) error {
624647
return nil
625648
}
626649

650+
func runSessionEvents(ctx context.Context, cmd *cli.Command) error {
651+
conn, err := dialControl(int(cmd.Int("port")))
652+
if err != nil {
653+
return err
654+
}
655+
defer conn.Close()
656+
follow := cmd.Bool("follow")
657+
if !follow {
658+
_ = conn.SetReadDeadline(time.Now().Add(15 * time.Second))
659+
} else {
660+
go func() {
661+
<-ctx.Done()
662+
conn.Close() // ctrl-C stops streaming cleanly
663+
}()
664+
}
665+
last := int(cmd.Int("last"))
666+
if last == 0 {
667+
last = -1 // explicit 0: everything the daemon kept
668+
}
669+
if err := writeControlFrame(conn, controlRequest{Cmd: "events", Lines: last, Follow: follow, Logs: cmd.Bool("logs")}); err != nil {
670+
return err
671+
}
672+
asJSON := cmd.Bool("json")
673+
enc := json.NewEncoder(out.ResultWriter())
674+
_, err = streamControlReplies(conn, func(r controlReply) {
675+
if r.Event == nil {
676+
return
677+
}
678+
if asJSON {
679+
_ = enc.Encode(r.Event)
680+
return
681+
}
682+
if line := renderEventLine(*r.Event); line != "" {
683+
out.Result(line)
684+
}
685+
})
686+
if err != nil && ctx.Err() != nil {
687+
return nil
688+
}
689+
return err
690+
}
691+
627692
func runSessionHistory(ctx context.Context, cmd *cli.Command) error {
628-
reply, err := controlRoundTrip(int(cmd.Int("port")), controlRequest{Cmd: "history"}, 30*time.Second)
693+
reply, err := controlRoundTrip(int(cmd.Int("port")), controlRequest{Cmd: "chat-history"}, 30*time.Second)
629694
if err != nil {
630695
return err
631696
}
@@ -699,7 +764,7 @@ func runSessionStatus(ctx context.Context, cmd *cli.Command) error {
699764
}
700765
turns := strconv.Itoa(st.Turns)
701766
if st.UnseenEvents > 0 {
702-
turns += util.Dimmed(fmt.Sprintf(" (+%d agent event(s) not yet shown; run `lk agent debugger listen` to see them)", st.UnseenEvents))
767+
turns += util.Dimmed(fmt.Sprintf(" (+%d agent event(s) not yet shown; run `lk agent debugger wait-for-reply` to see them)", st.UnseenEvents))
703768
}
704769
out.Resultf("%s%s\n", label("Turns:"), turns)
705770
if st.IdleTimeoutSeconds > 0 {

cmd/lk/debugger_agent.go

Lines changed: 104 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,12 @@ import (
3838
// emits them verbatim, so the schema doubles as the machine-readable contract
3939
// for coding agents driving the CLI.
4040
type turnEvent struct {
41-
// Type is one of: message, tool_call, handoff, config, error, log.
41+
// Type is one of: message, tool_call, handoff, config, error, log, state.
42+
// "state" (agent state transitions, From → To) is only reported by the
43+
// events stream, never inside a turn.
4244
Type string `json:"type"`
45+
// Time is when the event happened (RFC 3339 with milliseconds), when known.
46+
Time string `json:"time,omitempty"`
4347
// Role is "user" or "assistant" for message events.
4448
Role string `json:"role,omitempty"`
4549
// Text is the message text, error message, or log line.
@@ -88,6 +92,8 @@ type textSession struct {
8892
mu sync.Mutex
8993
pending map[string]chan *agent.SessionResponse
9094
subs map[*eventSub]struct{}
95+
observers map[*eventSub]struct{} // `events --follow` taps; never affect buffering
96+
recent []turnEvent // ring of the latest events for `events`
9197
undelivered []turnEvent
9298
agentState agent.AgentState
9399
activity chan struct{}
@@ -112,13 +118,14 @@ func newTextSession(conn net.Conn, reader io.Reader) *textSession {
112118
reader = conn
113119
}
114120
s := &textSession{
115-
conn: conn,
116-
reader: reader,
117-
pending: make(map[string]chan *agent.SessionResponse),
118-
subs: make(map[*eventSub]struct{}),
119-
activity: make(chan struct{}, 1),
120-
turnSem: make(chan struct{}, 1),
121-
done: make(chan struct{}),
121+
conn: conn,
122+
reader: reader,
123+
pending: make(map[string]chan *agent.SessionResponse),
124+
subs: make(map[*eventSub]struct{}),
125+
observers: make(map[*eventSub]struct{}),
126+
activity: make(chan struct{}, 1),
127+
turnSem: make(chan struct{}, 1),
128+
done: make(chan struct{}),
122129
}
123130
go s.readLoop()
124131
return s
@@ -196,13 +203,26 @@ func (s *textSession) notifyActivity() {
196203
}
197204
}
198205

206+
// recentEventsMax bounds the ring of events kept for `events`.
207+
const recentEventsMax = 500
208+
209+
func eventTimestamp(t time.Time) string { return t.UTC().Format("2006-01-02T15:04:05.000Z07:00") }
210+
199211
func (s *textSession) handleEvent(ev *agent.AgentSessionEvent) {
200212
if ev == nil {
201213
return
202214
}
215+
now := eventTimestamp(time.Now())
203216
if sc, ok := ev.Event.(*agent.AgentSessionEvent_AgentStateChanged_); ok && sc.AgentStateChanged != nil {
204217
s.mu.Lock()
218+
old := s.agentState
205219
s.agentState = sc.AgentStateChanged.NewState
220+
// State transitions are noise inside a turn but useful on the event
221+
// stream, so they go to observers (and the ring) only.
222+
s.publishLocked([]turnEvent{{
223+
Type: "state", Time: now,
224+
From: agentStateName(old), To: agentStateName(sc.AgentStateChanged.NewState),
225+
}}, false)
206226
s.mu.Unlock()
207227
s.notifyActivity()
208228
return
@@ -211,21 +231,81 @@ func (s *textSession) handleEvent(ev *agent.AgentSessionEvent) {
211231
if len(events) == 0 {
212232
return
213233
}
234+
// Live events are stamped on arrival so the stream reads in order; the
235+
// SDK's own created_at (kept for chat-history items) can predate the state
236+
// transitions that surround it.
237+
for i := range events {
238+
events[i].Time = now
239+
}
214240
s.mu.Lock()
241+
s.publishLocked(events, true)
242+
s.mu.Unlock()
243+
s.notifyActivity()
244+
}
245+
246+
// publishLocked records events in the ring and hands them to observers; when
247+
// toTurns is set they also go to turn subscribers, or to the undelivered
248+
// buffer if nobody is listening. Caller holds s.mu.
249+
func (s *textSession) publishLocked(events []turnEvent, toTurns bool) {
250+
s.recent = append(s.recent, events...)
251+
if over := len(s.recent) - recentEventsMax; over > 0 {
252+
s.recent = append([]turnEvent(nil), s.recent[over:]...)
253+
}
254+
for obs := range s.observers {
255+
for _, e := range events {
256+
select {
257+
case obs.ch <- e:
258+
default:
259+
}
260+
}
261+
}
262+
if !toTurns {
263+
return
264+
}
215265
if len(s.subs) == 0 {
216266
s.undelivered = append(s.undelivered, events...)
217-
} else {
218-
for sub := range s.subs {
219-
for _, e := range events {
220-
select {
221-
case sub.ch <- e:
222-
default:
223-
}
267+
return
268+
}
269+
for sub := range s.subs {
270+
for _, e := range events {
271+
select {
272+
case sub.ch <- e:
273+
default:
224274
}
225275
}
226276
}
277+
}
278+
279+
// observe taps the live event stream without affecting what turns see. It
280+
// returns the subscription and the most recent `last` events (all if last <= 0).
281+
func (s *textSession) observe(last int) (*eventSub, []turnEvent) {
282+
obs := &eventSub{ch: make(chan turnEvent, 1024)}
283+
s.mu.Lock()
284+
recent := s.recent
285+
if last > 0 && last < len(recent) {
286+
recent = recent[len(recent)-last:]
287+
}
288+
snapshot := append([]turnEvent(nil), recent...)
289+
s.observers[obs] = struct{}{}
290+
s.mu.Unlock()
291+
return obs, snapshot
292+
}
293+
294+
func (s *textSession) unobserve(obs *eventSub) {
295+
s.mu.Lock()
296+
delete(s.observers, obs)
227297
s.mu.Unlock()
228-
s.notifyActivity()
298+
}
299+
300+
// RecentEvents returns the most recent `last` events (all if last <= 0).
301+
func (s *textSession) RecentEvents(last int) []turnEvent {
302+
s.mu.Lock()
303+
defer s.mu.Unlock()
304+
recent := s.recent
305+
if last > 0 && last < len(recent) {
306+
recent = recent[len(recent)-last:]
307+
}
308+
return append([]turnEvent(nil), recent...)
229309
}
230310

231311
// subscribe starts receiving live events and, atomically with that, returns
@@ -629,7 +709,11 @@ func chatItemsToTurnEvents(items []*agent.ChatContext_ChatItem) []turnEvent {
629709
case *agent.ChatContext_ChatItem_FunctionCall:
630710
fc := i.FunctionCall
631711
callIndex[fc.GetCallId()] = len(events)
632-
events = append(events, turnEvent{Type: "tool_call", Name: fc.GetName(), Arguments: fc.GetArguments()})
712+
e := turnEvent{Type: "tool_call", Name: fc.GetName(), Arguments: fc.GetArguments()}
713+
if ts := fc.GetCreatedAt(); ts != nil {
714+
e.Time = eventTimestamp(ts.AsTime())
715+
}
716+
events = append(events, e)
633717
case *agent.ChatContext_ChatItem_FunctionCallOutput:
634718
fco := i.FunctionCallOutput
635719
if idx, ok := callIndex[fco.GetCallId()]; ok {
@@ -689,6 +773,9 @@ func messageToTurnEvent(msg *agent.ChatMessage) (turnEvent, bool) {
689773
return turnEvent{}, false
690774
}
691775
e := turnEvent{Type: "message", Role: role, Text: text, Interrupted: msg.GetInterrupted()}
776+
if ts := msg.GetCreatedAt(); ts != nil {
777+
e.Time = eventTimestamp(ts.AsTime())
778+
}
692779
if m := msg.GetMetrics(); m != nil {
693780
metrics := map[string]float64{}
694781
if m.LlmNodeTtft != nil {

0 commit comments

Comments
 (0)