mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-06 01:50:14 +02:00
* fix(agent): make cursor stream protocol drift loud instead of silent (MUL-5434) #6071 reports Cursor tasks that demonstrably read files, ran commands and called the Multica CLI, yet showed a single blob of agent text with no reasoning and no tool rows. The run still reported success and tools=0. `switch evt.Type` in cursor.go had no `default` branch, so any top-level event type we do not handle was dropped with no counter and no warning. Renaming only the top-level types of a healthy stream (`thinking`->`reasoning`, `tool_call`->`tool_calls`), leaving every nested field untouched, reproduces the report exactly: status=completed, output = the result text alone, tool_use=0, thinking=0, zero diagnostics. The existing unknown-subtype warning cannot catch this — it only increments once the type has already matched — so "no unknown-subtype warning" does not rule out protocol drift. Two diagnostic gaps are closed: - Add the `default` branch with a bounded, content-free tally of unhandled top-level types, reported once per run as a warning and alongside tool_use_count in the protocol summary. Type names are normalized through observedCursorEventType and distinct names are capped at 16 plus an overflow bucket, so a hostile or noisy stream cannot grow the map or leak payload into logs. `user` (the CLI echoing our prompt, present in every recorded run) is explicitly benign so the warning does not fire always. - Count assistant text separately from the terminal result text. The result event writes into the same builder, so last_assistant_bytes equalled result_bytes even when the assistant streamed nothing — erasing the signal that says "only the final answer arrived". This also aligns cursor with claude, which already reports assistant bytes only. Unrecognized events are still never coerced into tool or reasoning messages; guessing at upstream additions is the failure mode MUL-5231 already fixed once. This is diagnosis only and does not itself restore the missing tool rows — identifying which upstream shape changed requires a captured 2026.07.23 stream, and this warning is what makes that identifiable from a single production log line. Co-authored-by: multica-agent <github@multica.ai> * fix(agent): report cursor unhandled events as evidence, not as a verdict Addresses the review's must-fix on MUL-5434. The diagnostic was described as deciding WHY a transcript is empty, which it cannot do: - "tools=0 with unhandled types" does not establish that a rename ate the tool rows. Cursor 2026.07.23 also emits transport/control frames, so an unhandled type only proves the stream carried events we do not parse. - "tools=0 with no unhandled types" does not establish the agent used no tools. The CLI may execute tools without handing the updates to its stream serializer at all — the main branch #6071 has NOT ruled out — a new shape may be nested inside an event type we already recognize, or events may be lost to invalid framing or a scanner boundary. Changes, no behaviour change to messages, status or output: - Reword the tally doc, the switch default, the warning and the shared observation struct to state what a non-zero and a zero count each do and do not establish, and point at the branches that stay open. - Rename unknown* to unhandled* (fields, log keys, warning text, the pre-existing subtype counter) so the diagnostic never implies the type is unrecognized upstream — only that this parser does not handle it. - Classify `connection` and `retry` explicitly. They join `user` in cursorNonTranscriptEventTypes, with per-entry provenance recorded: `user` is confirmed in the recorded 2026.07.20 stream, the control frames are reported on newer builds and listed defensively. A build that does not emit them makes the entry inert; one that does must not have a known control frame reported as an unhandled protocol event. Tests assert signal presence and that nothing is fabricated, not causality: the healthy-stream test now carries `connection` / `retry` and additionally asserts the real thinking/tool rows still arrive, so suppressing the warning cannot silently suppress the transcript. TestCursorNonTranscriptEventType also pins that no type the parser handles can enter the suppression list. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
159 lines
5.4 KiB
Go
159 lines
5.4 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os/exec"
|
|
"time"
|
|
)
|
|
|
|
const emptySuccessfulStreamResult = "The agent completed without a final response."
|
|
|
|
// streamTerminalState keeps the user-facing final answer separate from the
|
|
// streamed assistant turns. Assistant messages are still emitted through the
|
|
// Session.Messages channel for live progress/transcript storage, but only a
|
|
// terminal result (or the last complete assistant message after an explicitly
|
|
// successful empty result) may become Result.Output.
|
|
type streamTerminalState struct {
|
|
lastAssistantText string
|
|
finalResultText string
|
|
sawResult bool
|
|
resultIsError bool
|
|
scanErr error
|
|
}
|
|
|
|
// finalizeStreamResult applies the shared fail-closed terminal contract used by
|
|
// Claude Code and CodeBuddy. A clean process exit is not proof that the
|
|
// stream-json protocol completed: success requires a result event. Failed runs
|
|
// always return an empty output so upstream issue/chat fallbacks can never
|
|
// mistake a partial transcript for a final answer.
|
|
func finalizeStreamResult(
|
|
provider string,
|
|
timeout time.Duration,
|
|
runErr error,
|
|
writeErr error,
|
|
exitErr error,
|
|
sessionID string,
|
|
state streamTerminalState,
|
|
completionGuardError string,
|
|
) (status, output, errMsg string) {
|
|
status = "completed"
|
|
if state.resultIsError {
|
|
status = "failed"
|
|
errMsg = state.finalResultText
|
|
if errMsg == "" {
|
|
errMsg = provider + " returned an error result without details"
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case status == "completed" && errors.Is(runErr, context.DeadlineExceeded):
|
|
status = "timeout"
|
|
errMsg = fmt.Sprintf("%s timed out after %s", provider, timeout)
|
|
case status == "completed" && errors.Is(runErr, context.Canceled):
|
|
status = "aborted"
|
|
errMsg = "execution cancelled"
|
|
case state.scanErr != nil && status == "completed":
|
|
status = "failed"
|
|
errMsg = fmt.Sprintf("%s stdout read error: %v", provider, state.scanErr)
|
|
case writeErr != nil && status == "completed" && sessionID == "":
|
|
status = "failed"
|
|
errMsg = fmt.Sprintf("write %s input: %v", provider, writeErr)
|
|
case exitErr != nil && status == "completed":
|
|
status = "failed"
|
|
errMsg = fmt.Sprintf("%s exited with error: %v", provider, exitErr)
|
|
case !state.sawResult && status == "completed":
|
|
status = "failed"
|
|
errMsg = provider + " stream ended without terminal result"
|
|
}
|
|
|
|
if status == "completed" && completionGuardError != "" {
|
|
status = "failed"
|
|
errMsg = completionGuardError
|
|
}
|
|
|
|
if status != "completed" {
|
|
return status, "", errMsg
|
|
}
|
|
if state.finalResultText != "" {
|
|
return status, state.finalResultText, ""
|
|
}
|
|
if state.lastAssistantText != "" {
|
|
return status, state.lastAssistantText, ""
|
|
}
|
|
return status, emptySuccessfulStreamResult, ""
|
|
}
|
|
|
|
type streamProtocolObservation struct {
|
|
provider string
|
|
cliVersion string
|
|
model string
|
|
exitCode int
|
|
eventCount int
|
|
invalidEventCount int
|
|
assistantEventCount int
|
|
toolUseCount int
|
|
sawResult bool
|
|
resultIsError bool
|
|
resultBytes int
|
|
lastAssistantBytes int
|
|
scannerError bool
|
|
lastEventType string
|
|
anthropicBaseURLConfigured bool
|
|
// unhandledEventTypeCount / unhandledEventTypes / unhandledSubtypeCount
|
|
// report stream events the parser did not turn into messages. They belong on
|
|
// this line rather than only in a separate warning so they can be read
|
|
// together with toolUseCount and invalidEventCount.
|
|
//
|
|
// They are evidence, not a verdict. A non-zero count means the stream
|
|
// carried events we do not handle and is the starting point for identifying
|
|
// a protocol change; a zero count means only that none were observed at the
|
|
// top level, and does not establish that the agent used no tools — a CLI can
|
|
// execute tools without serializing the updates at all, and a new shape can
|
|
// be nested inside a type we already recognize. Set by providers that track
|
|
// them; zero elsewhere.
|
|
unhandledEventTypeCount int
|
|
unhandledEventTypes string
|
|
unhandledSubtypeCount int
|
|
}
|
|
|
|
// logStreamProtocolObservation records only protocol metadata. It deliberately
|
|
// excludes assistant/result text, tool input/output, the configured base URL,
|
|
// and environment values so diagnosing a missing terminal event cannot leak the
|
|
// task transcript or provider credentials into daemon logs.
|
|
func logStreamProtocolObservation(logger *slog.Logger, obs streamProtocolObservation) {
|
|
logger.Info("agent stream protocol summary",
|
|
"provider", obs.provider,
|
|
"cli_version", obs.cliVersion,
|
|
"model", obs.model,
|
|
"exit_code", obs.exitCode,
|
|
"event_count", obs.eventCount,
|
|
"invalid_event_count", obs.invalidEventCount,
|
|
"assistant_event_count", obs.assistantEventCount,
|
|
"tool_use_count", obs.toolUseCount,
|
|
"saw_result", obs.sawResult,
|
|
"result_is_error", obs.resultIsError,
|
|
"result_bytes", obs.resultBytes,
|
|
"last_assistant_bytes", obs.lastAssistantBytes,
|
|
"scanner_error", obs.scannerError,
|
|
"last_event_type", obs.lastEventType,
|
|
"unhandled_event_type_count", obs.unhandledEventTypeCount,
|
|
"unhandled_event_types", obs.unhandledEventTypes,
|
|
"unhandled_subtype_count", obs.unhandledSubtypeCount,
|
|
"anthropic_base_url_configured", obs.anthropicBaseURLConfigured,
|
|
)
|
|
}
|
|
|
|
func streamProcessExitCode(err error) int {
|
|
if err == nil {
|
|
return 0
|
|
}
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
return exitErr.ExitCode()
|
|
}
|
|
return -1
|
|
}
|