Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
version: "2"

# Report every finding — the defaults (max-same-issues: 3,
# max-issues-per-linter: 50) silently hide repeats, which masks the true
# count.
issues:
max-same-issues: 0
max-issues-per-linter: 0

# Default linter set (standard: errcheck, govet, ineffassign, staticcheck,
# unused). Only the errcheck exclusions below are customized.
linters:
settings:
errcheck:
# Conventional "safe to ignore" calls: the returned error is not
# actionable at the call site.
exclude-functions:
- (io.Closer).Close # interface-typed Close (e.g. resp.Body)
- (*os.File).Close # defer file/tmpFile Close
- (*database/sql.Rows).Close # defer rows.Close
- fmt.Fprint
- fmt.Fprintf
- fmt.Fprintln # writes to http.ResponseWriter / buffers
- os.Remove # temp-file cleanup on an error path
exclusions:
rules:
# Test setup routinely ignores errors from helpers (SaveHistory,
# RunIteration, Execute, …); checking them adds noise without value.
- path: _test\.go
linters:
- errcheck
# SDK stream readers (anthropic ssestream / go-openai) expose Close via
# an embedded generic type errcheck cannot address by name; the deferred
# Close error is not actionable once the stream has been drained.
- linters:
- errcheck
source: 'defer stream\.Close\(\)'
21 changes: 12 additions & 9 deletions examples/creative_studio/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@
// and short videos from natural-language descriptions.
//
// Required env vars:
// OPENAI_API_KEY — image generation (DALL-E 3)
// GEMINI_API_KEY — video generation (Veo) + optional LLM
// LLM_PROVIDER — "openai" (default), "anthropic", or "gemini"
//
// OPENAI_API_KEY — image generation (DALL-E 3)
// GEMINI_API_KEY — video generation (Veo) + optional LLM
// LLM_PROVIDER — "openai" (default), "anthropic", or "gemini"
//
// Optional env vars:
// VEO_MODEL — video model ID. Defaults to "veo-2.0-generate-001"
// (silent video). Set to a Veo 3 model ID to get native
// audio; availability and pricing differ per tier.
// MEDIA_DIR — where generated images/videos are saved (default "generated").
//
// VEO_MODEL — video model ID. Defaults to "veo-2.0-generate-001"
// (silent video). Set to a Veo 3 model ID to get native
// audio; availability and pricing differ per tier.
// MEDIA_DIR — where generated images/videos are saved (default "generated").
//
// Run:
// cd examples/creative_studio && go run .
// open http://localhost:8890
//
// cd examples/creative_studio && go run .
// open http://localhost:8890
package main

import (
Expand Down
4 changes: 2 additions & 2 deletions examples/demo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func buildSessionManager(systemPrompt string) agent.SessionManager {
// pendingApprovals holds HITL confirmations that are waiting on the user.
// Maps approvalID → channel that receives true (approved) or false (denied).
var (
pendingMu sync.Mutex
pendingMu sync.Mutex
pendingApprovals = make(map[string]chan bool)
)

Expand Down Expand Up @@ -320,7 +320,7 @@ func uniqueID() uint64 {
// activeStreams maps sessionKey → the current SSE write channel (or nil).
var (
streamsMu sync.RWMutex
activeStreams = make(map[string]chan<- agent.StreamEvent)
activeStreams = make(map[string]chan<- agent.StreamEvent)
)

func registerStream(sessionKey string, ch chan<- agent.StreamEvent) {
Expand Down
1 change: 0 additions & 1 deletion examples/memory_demo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,3 @@ func dumpNotes(ctx context.Context, store memory.Store, scope string) {
fmt.Printf(" - [%s] %s\n", n.Key, n.Content)
}
}

5 changes: 1 addition & 4 deletions pkg/agent/auto_cache_system_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,7 @@ type cacheHintCapturingProvider struct {

func (p *cacheHintCapturingProvider) GenerateStream(_ context.Context, msgs []history.Message, _ *tools.Registry, ch chan<- StreamEvent) (LLMResult, error) {
p.mu.Lock()
stamp := false
if len(msgs) > 0 && msgs[0].Role == "system" && msgs[0].CacheHint {
stamp = true
}
stamp := len(msgs) > 0 && msgs[0].Role == "system" && msgs[0].CacheHint
p.seenStamps = append(p.seenStamps, stamp)
p.mu.Unlock()
ch <- Event(ContentEvent{Text: "ok"})
Expand Down
3 changes: 1 addition & 2 deletions pkg/agent/cache_gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import (
type gateTool struct {
name string
cacheable bool
hasFlag bool
calls atomic.Int32
}

Expand All @@ -37,7 +36,7 @@ type cacheableGateTool struct{ gateTool }

func (c *cacheableGateTool) Descriptor() tools.ToolDescriptor {
d := c.gateTool.Descriptor()
d.Cacheable = c.gateTool.cacheable
d.Cacheable = c.cacheable
return d
}

Expand Down
5 changes: 0 additions & 5 deletions pkg/agent/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,6 @@ package agent
// Internal tuning constants for the agent loop. Pulled out of inline
// literals so they can be cited and adjusted in one place.
const (
// runIterationBuffer sizes the channel RunIteration uses to receive
// events from the loop goroutine. Larger than the streaming buffers
// because the synchronous reader has no consumer back-pressure.
runIterationBuffer = 100

// runIterationStreamBuffer sizes the internal channel between the
// loop goroutine and the SSE proxy goroutine in RunIterationStream.
runIterationStreamBuffer = 50
Expand Down
28 changes: 14 additions & 14 deletions pkg/agent/event_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,8 @@ type ContentEvent struct {
Text string `json:"text"`
}

func (ContentEvent) isEventPayload() {}
func (ContentEvent) eventType() StreamEventType { return EventTypeContent }
func (ContentEvent) isEventPayload() {}
func (ContentEvent) eventType() StreamEventType { return EventTypeContent }

// ThoughtEvent is internal reasoning / system narration. Suppressed from the
// final answer by RunIteration; surfaced by RunIterationStream when
Expand All @@ -156,8 +156,8 @@ type ThoughtEvent struct {
Message string `json:"message"`
}

func (ThoughtEvent) isEventPayload() {}
func (ThoughtEvent) eventType() StreamEventType { return EventTypeThought }
func (ThoughtEvent) isEventPayload() {}
func (ThoughtEvent) eventType() StreamEventType { return EventTypeThought }

// ToolCallEvent announces that the agent is about to execute a tool. ID is
// the agent-generated correlation ID — it matches the toolCallID parameter on
Expand All @@ -172,8 +172,8 @@ type ToolCallEvent struct {
Reused bool `json:"reused,omitempty"`
}

func (ToolCallEvent) isEventPayload() {}
func (ToolCallEvent) eventType() StreamEventType { return EventTypeToolCall }
func (ToolCallEvent) isEventPayload() {}
func (ToolCallEvent) eventType() StreamEventType { return EventTypeToolCall }

// ToolProgressEvent is a mid-execution status update emitted by a tool via
// tools.ReportProgress. Progress is lossy by design — consumers may drop
Expand Down Expand Up @@ -204,8 +204,8 @@ type UsageEvent struct {
Usage TokenUsage `json:"usage"`
}

func (UsageEvent) isEventPayload() {}
func (UsageEvent) eventType() StreamEventType { return EventTypeUsage }
func (UsageEvent) isEventPayload() {}
func (UsageEvent) eventType() StreamEventType { return EventTypeUsage }

// ErrorEvent signals a terminal failure for the current iteration. Err holds
// the structured error (usable with errors.Is / errors.As); Message is its
Expand All @@ -215,14 +215,14 @@ type ErrorEvent struct {
Message string `json:"message"`
}

func (ErrorEvent) isEventPayload() {}
func (ErrorEvent) eventType() StreamEventType { return EventTypeError }
func (ErrorEvent) isEventPayload() {}
func (ErrorEvent) eventType() StreamEventType { return EventTypeError }

// DoneEvent marks the end of the stream — no more events will arrive.
type DoneEvent struct{}

func (DoneEvent) isEventPayload() {}
func (DoneEvent) eventType() StreamEventType { return EventTypeDone }
func (DoneEvent) isEventPayload() {}
func (DoneEvent) eventType() StreamEventType { return EventTypeDone }

// ReflectedEvent delivers a post-critique canonical answer. Round indicates
// which self-critique pass produced it (1-indexed); consumers typically keep
Expand Down Expand Up @@ -265,8 +265,8 @@ type TaskListEvent struct {
Tasks []TaskListItem `json:"tasks"`
}

func (TaskListEvent) isEventPayload() {}
func (TaskListEvent) eventType() StreamEventType { return EventTypeTaskList }
func (TaskListEvent) isEventPayload() {}
func (TaskListEvent) eventType() StreamEventType { return EventTypeTaskList }

// MaxItersReachedEvent signals that the loop exhausted its iteration cap
// without a final answer. Limit echoes AgentLoop.MaxIters at the time of
Expand Down
44 changes: 23 additions & 21 deletions pkg/agent/event_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,30 +147,32 @@ type recordingVisitor struct {
visited string
}

func (r *recordingVisitor) VisitContent(ContentEvent) { r.visited = "content" }
func (r *recordingVisitor) VisitThought(ThoughtEvent) { r.visited = "thought" }
func (r *recordingVisitor) VisitToolCall(ToolCallEvent) { r.visited = "tool_call" }
func (r *recordingVisitor) VisitToolProgress(ToolProgressEvent) { r.visited = "tool_progress" }
func (r *recordingVisitor) VisitActionRequired(ActionRequiredEvent) { r.visited = "action_required" }
func (r *recordingVisitor) VisitUsage(UsageEvent) { r.visited = "usage" }
func (r *recordingVisitor) VisitError(ErrorEvent) { r.visited = "error" }
func (r *recordingVisitor) VisitDone(DoneEvent) { r.visited = "done" }
func (r *recordingVisitor) VisitReflected(ReflectedEvent) { r.visited = "reflected" }
func (r *recordingVisitor) VisitToolCallReady(ToolCallReadyEvent) { r.visited = "tool_call_ready" }
func (r *recordingVisitor) VisitTaskList(TaskListEvent) { r.visited = "task_list" }
func (r *recordingVisitor) VisitMaxItersReached(MaxItersReachedEvent) { r.visited = "max_iters_reached" }
func (r *recordingVisitor) VisitSessionCreated(SessionCreatedEvent) { r.visited = "session_created" }
func (r *recordingVisitor) VisitLimitExhausted(LimitExhaustedEvent) { r.visited = "limit_exhausted" }
func (r *recordingVisitor) VisitHITLDenied(HITLDeniedEvent) { r.visited = "hitl_denied" }
func (r *recordingVisitor) VisitHITLTimedOut(HITLTimedOutEvent) { r.visited = "hitl_timed_out" }
func (r *recordingVisitor) VisitRegenerated(RegeneratedEvent) { r.visited = "regenerated" }
func (r *recordingVisitor) VisitContinued(ContinuedEvent) { r.visited = "continued" }
func (r *recordingVisitor) VisitMemoryLoaded(MemoryLoadedEvent) { r.visited = "memory_loaded" }
func (r *recordingVisitor) VisitContent(ContentEvent) { r.visited = "content" }
func (r *recordingVisitor) VisitThought(ThoughtEvent) { r.visited = "thought" }
func (r *recordingVisitor) VisitToolCall(ToolCallEvent) { r.visited = "tool_call" }
func (r *recordingVisitor) VisitToolProgress(ToolProgressEvent) { r.visited = "tool_progress" }
func (r *recordingVisitor) VisitActionRequired(ActionRequiredEvent) { r.visited = "action_required" }
func (r *recordingVisitor) VisitUsage(UsageEvent) { r.visited = "usage" }
func (r *recordingVisitor) VisitError(ErrorEvent) { r.visited = "error" }
func (r *recordingVisitor) VisitDone(DoneEvent) { r.visited = "done" }
func (r *recordingVisitor) VisitReflected(ReflectedEvent) { r.visited = "reflected" }
func (r *recordingVisitor) VisitToolCallReady(ToolCallReadyEvent) { r.visited = "tool_call_ready" }
func (r *recordingVisitor) VisitTaskList(TaskListEvent) { r.visited = "task_list" }
func (r *recordingVisitor) VisitMaxItersReached(MaxItersReachedEvent) {
r.visited = "max_iters_reached"
}
func (r *recordingVisitor) VisitSessionCreated(SessionCreatedEvent) { r.visited = "session_created" }
func (r *recordingVisitor) VisitLimitExhausted(LimitExhaustedEvent) { r.visited = "limit_exhausted" }
func (r *recordingVisitor) VisitHITLDenied(HITLDeniedEvent) { r.visited = "hitl_denied" }
func (r *recordingVisitor) VisitHITLTimedOut(HITLTimedOutEvent) { r.visited = "hitl_timed_out" }
func (r *recordingVisitor) VisitRegenerated(RegeneratedEvent) { r.visited = "regenerated" }
func (r *recordingVisitor) VisitContinued(ContinuedEvent) { r.visited = "continued" }
func (r *recordingVisitor) VisitMemoryLoaded(MemoryLoadedEvent) { r.visited = "memory_loaded" }
func (r *recordingVisitor) VisitMemoryConsolidated(MemoryConsolidatedEvent) {
r.visited = "memory_consolidated"
}
func (r *recordingVisitor) VisitRunCost(RunCostEvent) { r.visited = "run_cost" }
func (r *recordingVisitor) VisitUnknown(UnknownEvent) { r.visited = "unknown" }
func (r *recordingVisitor) VisitRunCost(RunCostEvent) { r.visited = "run_cost" }
func (r *recordingVisitor) VisitUnknown(UnknownEvent) { r.visited = "unknown" }

func TestVisit_DispatchesToMatchingMethod(t *testing.T) {
cases := []struct {
Expand Down
1 change: 0 additions & 1 deletion pkg/agent/loop_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ type iterationState struct {
sessionKey string
iteration int
streamChan chan<- StreamEvent
msgs *[]history.Message
specMap map[string]*speculativeExec
specMu *sync.Mutex
tracker *loopDetector
Expand Down
4 changes: 2 additions & 2 deletions pkg/agent/loop_stream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -483,8 +483,8 @@ func TestRunIteration_EventHandlerMultiple(t *testing.T) {
func TestRunIteration_RetryOnLLMError(t *testing.T) {
attempts := 0
provider := &countingErrorProvider{
failN: 2, // fail first 2 attempts, succeed on 3rd
onCall: func() { attempts++ },
failN: 2, // fail first 2 attempts, succeed on 3rd
onCall: func() { attempts++ },
successResult: LLMResult{Content: "recovered"},
}
loop, _ := setup(provider)
Expand Down
1 change: 0 additions & 1 deletion pkg/agent/memory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -779,4 +779,3 @@ type consolidatorPanicProvider struct{}
func (p *consolidatorPanicProvider) GenerateStream(_ context.Context, _ []history.Message, _ *tools.Registry, _ chan<- StreamEvent) (LLMResult, error) {
panic("provider should not be called for short transcripts")
}

2 changes: 1 addition & 1 deletion pkg/agent/mock_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func (m *MockProvider) GenerateStream(ctx context.Context, memory []history.Mess
toolName = "delete_database_records"
argsJSON = `{"table": "users", "condition": "all"}`
}

// If running inside SQL Sub-Agent, it should call execute_sql instead of call_sql_agent
if _, hasExecuteSQL := registry.Get("execute_sql"); hasExecuteSQL {
toolName = "execute_sql"
Expand Down
18 changes: 0 additions & 18 deletions pkg/agent/reflect.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package agent

import (
"context"
"encoding/json"
"fmt"
"strings"

Expand Down Expand Up @@ -94,20 +93,3 @@ func (al *AgentLoop) reflectOnce(
}
return strings.TrimSpace(buf.String()), nil
}

// reflectedEventContent serializes the ReflectedEvent payload into the
// string channel used by StreamEvent. JSON keeps text and round coupled so
// downstream consumers deserialize with a single Unmarshal.
func reflectedEventContent(text string, round int) string {
payload := struct {
Text string `json:"text"`
Round int `json:"round"`
}{Text: text, Round: round}
b, err := json.Marshal(payload)
if err != nil {
// Marshal of a string+int can't realistically fail; fall back to
// raw text so the consumer still sees the answer.
return text
}
return string(b)
}
2 changes: 1 addition & 1 deletion pkg/agent/soft_landing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func TestSoftLanding_NotPersistedToHistory(t *testing.T) {
}

type toolEverProvider struct {
name string
name string
calls int
}

Expand Down
4 changes: 2 additions & 2 deletions pkg/agent/structured_output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ func TestStructuredOutput_IsolatedFromOriginal(t *testing.T) {

func TestStructuredOutput_EmptyOrNilSchemaClears(t *testing.T) {
cases := []StructuredOutput{
{}, // zero value
{Name: "x"}, // no schema
{}, // zero value
{Name: "x"}, // no schema
{Schema: map[string]any{}}, // empty schema
}
for i, so := range cases {
Expand Down
5 changes: 2 additions & 3 deletions pkg/agent/tool_error_hint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,8 @@ func TestFormatToolError_ContextCarriesArgsAndIteration(t *testing.T) {
// --- integration: error flows into tool result message ---

type failingTool struct {
mu sync.Mutex
nCalls int
failOnce bool
mu sync.Mutex
nCalls int
}

func (t *failingTool) Descriptor() tools.ToolDescriptor {
Expand Down
2 changes: 1 addition & 1 deletion pkg/agentmetrics/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func TestHandler_DeterministicOrdering(t *testing.T) {
a := strings.Index(body, `gopheragent_session_prompt_tokens_total{session_key="a"}`)
b := strings.Index(body, `gopheragent_session_prompt_tokens_total{session_key="b"}`)
c := strings.Index(body, `gopheragent_session_prompt_tokens_total{session_key="c"}`)
if !(a < b && b < c) {
if a >= b || b >= c {
t.Fatalf("session keys must be sorted; got indices a=%d b=%d c=%d", a, b, c)
}
}
Expand Down
10 changes: 5 additions & 5 deletions pkg/builder/knowledge_base_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func TestLoadKnowledgeBase_DeterministicOrdering(t *testing.T) {
idxA := strings.Index(first, "a.md")
idxB := strings.Index(first, "b.md")
idxC := strings.Index(first, "c.md")
if !(idxA < idxB && idxB < idxC) {
if idxA >= idxB || idxB >= idxC {
t.Fatalf("expected alphabetical ordering a,b,c — got offsets a=%d b=%d c=%d", idxA, idxB, idxC)
}
}
Expand Down Expand Up @@ -244,9 +244,9 @@ func TestFormatKnowledgeBase_MatchesLoadOutputForEquivalentInput(t *testing.T) {

func TestFormatKnowledgeBase_DropsEmptyEntries(t *testing.T) {
out := FormatKnowledgeBase([]KBDocument{
{Path: "", Content: "orphan"}, // no path — drop
{Path: "blank.md", Content: ""}, // no content — drop
{Path: "real.md", Content: "kept"}, // keep
{Path: "", Content: "orphan"}, // no path — drop
{Path: "blank.md", Content: ""}, // no content — drop
{Path: "real.md", Content: "kept"}, // keep
})
if !strings.Contains(out, "kept") {
t.Fatal("valid entry dropped")
Expand Down Expand Up @@ -274,7 +274,7 @@ func TestFormatKnowledgeBase_SortsByPath(t *testing.T) {
idxA := strings.Index(out, "alpha.md")
idxM := strings.Index(out, "mid.md")
idxZ := strings.Index(out, "zeta.md")
if !(idxA < idxM && idxM < idxZ) {
if idxA >= idxM || idxM >= idxZ {
t.Fatalf("expected alphabetical ordering; offsets a=%d m=%d z=%d", idxA, idxM, idxZ)
}
}
Expand Down
Loading
Loading