-
Notifications
You must be signed in to change notification settings - Fork 61
feat: human-in-the-loop support with AskUserQuestion #897
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -439,15 +439,19 @@ func persistStreamedEvent(sessionID, runID, threadID, jsonData string) { | |
|
|
||
| persistEvent(sessionID, event) | ||
|
|
||
| // Update lastActivityTime on CR for activity events (debounced). | ||
| // Extract event type to check; projectName is derived from the | ||
| // Extract event type; projectName is derived from the | ||
| // sessionID-to-project mapping populated by HandleAGUIRunProxy. | ||
| eventType, _ := event["type"].(string) | ||
|
|
||
| // Update lastActivityTime on CR for activity events (debounced). | ||
| if isActivityEvent(eventType) { | ||
| if projectName, ok := sessionProjectMap.Load(sessionID); ok { | ||
| updateLastActivityTime(projectName.(string), sessionID, eventType == types.EventTypeRunStarted) | ||
|
Comment on lines
+442
to
449
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use a project-scoped key here, not Line 445 recovers Suggested direction- go proxyRunnerStream(runnerURL, bodyBytes, sessionName, runID, threadID)
+ go proxyRunnerStream(runnerURL, bodyBytes, projectName, sessionName, runID, threadID)
- func proxyRunnerStream(runnerURL string, bodyBytes []byte, sessionName, runID, threadID string) {
+ func proxyRunnerStream(runnerURL string, bodyBytes []byte, projectName, sessionName, runID, threadID string) {
- persistStreamedEvent(sessionName, runID, threadID, jsonData)
+ persistStreamedEvent(projectName, sessionName, runID, threadID, jsonData)
- func persistStreamedEvent(sessionID, runID, threadID, jsonData string) {
+ func persistStreamedEvent(projectName, sessionID, runID, threadID, jsonData string) {
- if projectName, ok := sessionProjectMap.Load(sessionID); ok {
- updateLastActivityTime(projectName.(string), sessionID, eventType == types.EventTypeRunStarted)
- }
+ updateLastActivityTime(projectName, sessionID, eventType == types.EventTypeRunStarted)🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
|
|
||
| // agentStatus is derived at query time from the event log (DeriveAgentStatus). | ||
| // No CR updates needed here — the persisted events ARE the source of truth. | ||
| } | ||
|
|
||
| // ─── POST /agui/interrupt ──────────────────────────────────────────── | ||
|
|
@@ -948,3 +952,16 @@ func updateLastActivityTime(projectName, sessionName string, immediate bool) { | |
| } | ||
| }() | ||
| } | ||
|
|
||
| // isAskUserQuestionToolCall checks if a tool call name is the AskUserQuestion HITL tool. | ||
| // Uses case-insensitive comparison after stripping non-alpha characters, | ||
| // matching the frontend pattern in use-agent-status.ts. | ||
| func isAskUserQuestionToolCall(name string) bool { | ||
| var clean strings.Builder | ||
| for _, r := range strings.ToLower(name) { | ||
| if r >= 'a' && r <= 'z' { | ||
| clean.WriteRune(r) | ||
| } | ||
| } | ||
| return clean.String() == "askuserquestion" | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,7 @@ package websocket | |
| import ( | ||
| "ambient-code-backend/types" | ||
| "bufio" | ||
| "bytes" | ||
| "encoding/json" | ||
| "fmt" | ||
| "log" | ||
|
|
@@ -210,6 +211,108 @@ func loadEvents(sessionID string) []map[string]interface{} { | |
| return events | ||
| } | ||
|
|
||
| // DeriveAgentStatus reads a session's event log and returns the agent | ||
| // status derived from the last significant events. | ||
| // | ||
| // Returns "" if the status cannot be determined (no events, file missing, etc.). | ||
| func DeriveAgentStatus(sessionID string) string { | ||
| path := fmt.Sprintf("%s/sessions/%s/agui-events.jsonl", StateBaseDir, sessionID) | ||
|
|
||
| // Read only the tail of the file to avoid loading entire event log into memory. | ||
| // 64KB is sufficient for recent lifecycle events (scanning backwards). | ||
| const maxTailBytes = 64 * 1024 | ||
|
|
||
| file, err := os.Open(path) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| defer file.Close() | ||
|
|
||
| stat, err := file.Stat() | ||
| if err != nil { | ||
| return "" | ||
| } | ||
|
|
||
| fileSize := stat.Size() | ||
| var data []byte | ||
|
|
||
| if fileSize <= maxTailBytes { | ||
| // File is small, read it all | ||
| data, err = os.ReadFile(path) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
|
Comment on lines
+239
to
+244
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Minor inefficiency: file opened twice for small files. The file is already open from line 209, but for small files, ♻️ Proposed fix to read from existing handle if fileSize <= maxTailBytes {
- // File is small, read it all
- data, err = os.ReadFile(path)
- if err != nil {
- return ""
- }
+ // File is small, read it all from the open handle
+ data = make([]byte, fileSize)
+ _, err = file.Read(data)
+ if err != nil {
+ return ""
+ }
} else {🤖 Prompt for AI Agents |
||
| } else { | ||
| // File is large, seek to tail and read last N bytes | ||
| offset := fileSize - maxTailBytes | ||
| _, err = file.Seek(offset, 0) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
|
|
||
| data = make([]byte, maxTailBytes) | ||
| n, err := file.Read(data) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| data = data[:n] | ||
|
|
||
| // Skip partial first line (we seeked into the middle of a line) | ||
| if idx := bytes.IndexByte(data, '\n'); idx >= 0 { | ||
| data = data[idx+1:] | ||
| } | ||
| } | ||
|
|
||
| lines := splitLines(data) | ||
|
|
||
| // Scan backwards. We only care about lifecycle and AskUserQuestion events. | ||
| // RUN_STARTED → "working" | ||
| // RUN_FINISHED / RUN_ERROR → "idle", unless same run had AskUserQuestion | ||
| // TOOL_CALL_START (AskUserQuestion) → "waiting_input" | ||
| var runEndRunID string // set when we hit RUN_FINISHED/RUN_ERROR and need to look deeper | ||
| for i := len(lines) - 1; i >= 0; i-- { | ||
| if len(lines[i]) == 0 { | ||
| continue | ||
| } | ||
| var evt map[string]interface{} | ||
| if err := json.Unmarshal(lines[i], &evt); err != nil { | ||
| continue | ||
| } | ||
| evtType, _ := evt["type"].(string) | ||
|
|
||
| switch evtType { | ||
| case types.EventTypeRunStarted: | ||
| if runEndRunID != "" { | ||
| // We were scanning for an AskUserQuestion but hit RUN_STARTED first → idle | ||
| return types.AgentStatusIdle | ||
| } | ||
| return types.AgentStatusWorking | ||
|
|
||
| case types.EventTypeRunFinished, types.EventTypeRunError: | ||
| if runEndRunID == "" { | ||
| // First run-end seen; scan deeper within this run for AskUserQuestion | ||
| runEndRunID, _ = evt["runId"].(string) | ||
| } | ||
|
|
||
| case types.EventTypeToolCallStart: | ||
| if runEndRunID != "" { | ||
| // Only relevant if we're scanning within the ended run | ||
| if evtRunID, _ := evt["runId"].(string); evtRunID != "" && evtRunID != runEndRunID { | ||
| return types.AgentStatusIdle | ||
| } | ||
| } | ||
| if toolName, _ := evt["toolCallName"].(string); isAskUserQuestionToolCall(toolName) { | ||
| return types.AgentStatusWaitingInput | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if runEndRunID != "" { | ||
| return types.AgentStatusIdle | ||
| } | ||
| return "" | ||
| } | ||
|
|
||
| // ─── Compaction ────────────────────────────────────────────────────── | ||
| // | ||
| // Go port of @ag-ui/client compactEvents. Concatenates streaming deltas | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use a namespaced key for derived agent status.
DeriveAgentStatusFromEventsonly receivessessionName, but session names are namespace-scoped and this file generates them fromsession-<unix seconds>. Two projects can legitimately share the same name, so list/detail can pick up another project's waiting/working state. Please threadproject/namespaceor a stable session UID through this lookup.Suggested fix
Also applies to: 367-383, 456-459, 933-934
🤖 Prompt for AI Agents