13b2f5e14d
Removes five unused funcs/vars/fields that golangci-lint had been flagging (anthropic.toolCallDoneEvent, mistral.translateMessages, hook.newError, subprocess.vibeParser.lastAssistantMsgID, tui.cBase), two ineffectual assignments (tui/rendering.go visible-window loop, subprocess stream_test setup), and a stale if/HasPrefix that's now a strings.TrimPrefix. Wires errcheck onto every subprocess / stream lifecycle path so a failed close or shutdown is at least logged rather than silently dropped: - engine/loop.go: stream.Close on both the error and success paths - mcp/manager.go: Shutdown when StartAll partial-fails; Transport close after Initialize failure - mcp/transport.go: stdin.Close + syscall.Kill on graceful-timeout fallback - slm/download.go: Close propagated as a named-return error on the success path; explicitly discarded on the rollback path - slm/classifier.go, slm/manager.go, hook/prompt.go, context/summarize.go, config/write.go, cmd/gnoma/main.go, tool/fs/grep.go: explicit ignores or error logging on Close / Shutdown / WalkDir / Scanln Production-code errcheck and ineffassign are now zero. Remaining golangci-lint output is test-only Close-in-defer noise plus stylistic staticcheck QF suggestions, left alone.
227 lines
6.3 KiB
Go
227 lines
6.3 KiB
Go
package subprocess
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"somegit.dev/Owlibou/gnoma/internal/message"
|
|
"somegit.dev/Owlibou/gnoma/internal/stream"
|
|
)
|
|
|
|
// FormatParser converts raw stdout lines from a CLI subprocess into stream.Events.
|
|
// Each CLI agent emits its own line-delimited JSON format.
|
|
//
|
|
// Design note: These agents are full agentic loops, not LLM endpoints. The provider.Request
|
|
// fields Tools, Messages (history), SystemPrompt, Temperature, Thinking, etc. are NOT honored —
|
|
// they are opaque black boxes. Only the latest user message is passed as a prompt. Internal
|
|
// tool calls executed by the CLI are surfaced as EventTextDelta (opaque text) in v1.
|
|
type FormatParser interface {
|
|
// ParseLine parses one newline-stripped stdout line. Returns 0 or more events.
|
|
ParseLine(line []byte) ([]stream.Event, error)
|
|
// Done is called when the process exits cleanly. May emit final events.
|
|
Done() []stream.Event
|
|
}
|
|
|
|
// --- claude-stream-json ---
|
|
// Format emitted by: claude -p "..." --output-format stream-json --verbose
|
|
//
|
|
// Relevant event types:
|
|
// type=assistant → message.content[].type=text → EventTextDelta
|
|
// type=result → usage.input_tokens/output_tokens, stop_reason → EventUsage; is_error → EventError
|
|
|
|
type claudeParser struct{}
|
|
|
|
func newClaudeParser() FormatParser { return &claudeParser{} }
|
|
|
|
type claudeEvent struct {
|
|
Type string `json:"type"`
|
|
Subtype string `json:"subtype"`
|
|
Message *claudeMessage `json:"message,omitempty"`
|
|
IsError bool `json:"is_error,omitempty"`
|
|
// result fields
|
|
StopReason string `json:"stop_reason,omitempty"`
|
|
Usage *claudeUsage `json:"usage,omitempty"`
|
|
}
|
|
|
|
type claudeMessage struct {
|
|
Content []claudeContent `json:"content"`
|
|
Usage *claudeUsage `json:"usage,omitempty"`
|
|
}
|
|
|
|
type claudeContent struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text,omitempty"`
|
|
}
|
|
|
|
type claudeUsage struct {
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
}
|
|
|
|
func (p *claudeParser) ParseLine(line []byte) ([]stream.Event, error) {
|
|
var ev claudeEvent
|
|
if err := json.Unmarshal(line, &ev); err != nil {
|
|
return nil, fmt.Errorf("claude: parse line: %w", err)
|
|
}
|
|
|
|
switch ev.Type {
|
|
case "assistant":
|
|
if ev.Message == nil {
|
|
return nil, nil
|
|
}
|
|
var evts []stream.Event
|
|
for _, c := range ev.Message.Content {
|
|
if c.Type == "text" && c.Text != "" {
|
|
evts = append(evts, stream.Event{Type: stream.EventTextDelta, Text: c.Text})
|
|
}
|
|
}
|
|
return evts, nil
|
|
|
|
case "result":
|
|
if ev.IsError {
|
|
return []stream.Event{{
|
|
Type: stream.EventError,
|
|
Err: fmt.Errorf("claude CLI: %s", ev.Subtype),
|
|
}}, nil
|
|
}
|
|
var evts []stream.Event
|
|
if ev.Usage != nil {
|
|
evts = append(evts, stream.Event{
|
|
Type: stream.EventUsage,
|
|
Usage: &message.Usage{
|
|
InputTokens: ev.Usage.InputTokens,
|
|
OutputTokens: ev.Usage.OutputTokens,
|
|
},
|
|
StopReason: claudeStopReason(ev.StopReason),
|
|
})
|
|
}
|
|
return evts, nil
|
|
}
|
|
|
|
// system, rate_limit_event, tool, etc. — intentionally ignored
|
|
return nil, nil
|
|
}
|
|
|
|
func (p *claudeParser) Done() []stream.Event { return nil }
|
|
|
|
func claudeStopReason(s string) message.StopReason {
|
|
switch s {
|
|
case "end_turn":
|
|
return message.StopEndTurn
|
|
case "max_tokens":
|
|
return message.StopMaxTokens
|
|
default:
|
|
return message.StopEndTurn
|
|
}
|
|
}
|
|
|
|
// --- gemini-stream-json ---
|
|
// Format emitted by: gemini -p "..." --output-format stream-json
|
|
//
|
|
// Relevant event types:
|
|
// type=message, role=assistant, delta=true → EventTextDelta
|
|
// type=result, status=success → EventUsage
|
|
|
|
type geminiParser struct{}
|
|
|
|
func newGeminiParser() FormatParser { return &geminiParser{} }
|
|
|
|
type geminiEvent struct {
|
|
Type string `json:"type"`
|
|
Role string `json:"role,omitempty"`
|
|
Content string `json:"content,omitempty"`
|
|
Delta bool `json:"delta,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
Stats *geminiStats `json:"stats,omitempty"`
|
|
}
|
|
|
|
type geminiStats struct {
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
}
|
|
|
|
func (p *geminiParser) ParseLine(line []byte) ([]stream.Event, error) {
|
|
var ev geminiEvent
|
|
if err := json.Unmarshal(line, &ev); err != nil {
|
|
return nil, fmt.Errorf("gemini: parse line: %w", err)
|
|
}
|
|
|
|
switch ev.Type {
|
|
case "message":
|
|
if ev.Role == "assistant" && ev.Content != "" {
|
|
return []stream.Event{{Type: stream.EventTextDelta, Text: ev.Content}}, nil
|
|
}
|
|
// user messages and empty assistant messages are ignored
|
|
return nil, nil
|
|
|
|
case "result":
|
|
if ev.Stats == nil {
|
|
return nil, nil
|
|
}
|
|
stopReason := message.StopEndTurn
|
|
if ev.Status != "success" {
|
|
return []stream.Event{{
|
|
Type: stream.EventError,
|
|
Err: fmt.Errorf("gemini CLI: result status %q", ev.Status),
|
|
}}, nil
|
|
}
|
|
return []stream.Event{{
|
|
Type: stream.EventUsage,
|
|
Usage: &message.Usage{
|
|
InputTokens: ev.Stats.InputTokens,
|
|
OutputTokens: ev.Stats.OutputTokens,
|
|
},
|
|
StopReason: stopReason,
|
|
}}, nil
|
|
}
|
|
|
|
// init, other types — ignored
|
|
return nil, nil
|
|
}
|
|
|
|
func (p *geminiParser) Done() []stream.Event { return nil }
|
|
|
|
// --- vibe-streaming (mistral) ---
|
|
// Format emitted by: vibe -p "..." --output streaming --trust
|
|
//
|
|
// Each line is a JSON message object with a "role" field.
|
|
// role=assistant: content → EventTextDelta; reasoning_content → EventThinkingDelta
|
|
// role=system, role=user: ignored
|
|
// No explicit "done" event — stream ends when process exits.
|
|
|
|
type vibeParser struct{}
|
|
|
|
func newVibeParser() FormatParser { return &vibeParser{} }
|
|
|
|
type vibeMessage struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
ReasoningContent *string `json:"reasoning_content"`
|
|
MessageID *string `json:"message_id"`
|
|
}
|
|
|
|
func (p *vibeParser) ParseLine(line []byte) ([]stream.Event, error) {
|
|
var msg vibeMessage
|
|
if err := json.Unmarshal(line, &msg); err != nil {
|
|
return nil, fmt.Errorf("vibe: parse line: %w", err)
|
|
}
|
|
|
|
if msg.Role != "assistant" {
|
|
return nil, nil
|
|
}
|
|
|
|
var evts []stream.Event
|
|
if msg.ReasoningContent != nil && *msg.ReasoningContent != "" {
|
|
evts = append(evts, stream.Event{
|
|
Type: stream.EventThinkingDelta,
|
|
Text: *msg.ReasoningContent,
|
|
})
|
|
}
|
|
if msg.Content != "" {
|
|
evts = append(evts, stream.Event{Type: stream.EventTextDelta, Text: msg.Content})
|
|
}
|
|
return evts, nil
|
|
}
|
|
|
|
func (p *vibeParser) Done() []stream.Event { return nil }
|