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
9 changes: 9 additions & 0 deletions internal/agent/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"time"

"github.com/genai-io/san/internal/core"
"github.com/genai-io/san/internal/core/system"
Expand All @@ -24,6 +25,11 @@ type BuildParams struct {
CWDFunc func() string // dynamic CWD for tool execution; falls back to CWD if nil
IsGit bool

// Stream timeout tuning. Zero values (default) use the core defaults:
// FirstChunkTimeout = 5m, IdleTimeout = 3m.
StreamFirstChunkTimeout time.Duration
StreamIdleTimeout time.Duration

// AgentDirectory, when non-nil, supplies the available-agents listing
// embedded into the Agent tool's description. Returning an empty string
// hides the listing entirely (used by subagent contexts to discourage
Expand Down Expand Up @@ -131,6 +137,9 @@ func buildAgent(p BuildParams) (core.Agent, *PermissionGate, error) {
CompactFunc: compactFunc,
CWD: p.CWD,
OnEvent: p.OnEvent,

StreamFirstChunkTimeout: p.StreamFirstChunkTimeout,
StreamIdleTimeout: p.StreamIdleTimeout,
})

return ag, pg, nil
Expand Down
29 changes: 29 additions & 0 deletions internal/app/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,32 @@ func (m *model) buildAgentParams() agent.BuildParams {
// hot-swap it mid-session; the closures below Load it per call.
m.rebuildAutopilotReviewer()

// Read stream timeout overrides from settings (low priority) then env vars
// (high priority). Priority: SAN_* env var > settings.json > core default.
// Valid time.Duration strings (e.g. "30s", "3m"); empty/invalid leaves the
// core default (zero value => core picks FirstChunkTimeout=5m, IdleTimeout=60s).
var streamFirstChunk, streamIdle time.Duration
if s := m.services.Setting.StreamFirstChunkTimeout(); s != "" {
if d, err := time.ParseDuration(s); err == nil {
streamFirstChunk = d
}
}
if s := m.services.Setting.StreamIdleTimeout(); s != "" {
if d, err := time.ParseDuration(s); err == nil {
streamIdle = d
}
}
if s := setting.Getenv("STREAM_FIRST_CHUNK_TIMEOUT"); s != "" {
if d, err := time.ParseDuration(s); err == nil {
streamFirstChunk = d
}
}
if s := setting.Getenv("STREAM_IDLE_TIMEOUT"); s != "" {
if d, err := time.ParseDuration(s); err == nil {
streamIdle = d
}
}

return agent.BuildParams{
Provider: m.env.LLMProvider,
ModelID: m.env.GetModelID(),
Expand Down Expand Up @@ -244,6 +270,9 @@ func (m *model) buildAgentParams() agent.BuildParams {
m.recordDecision(ctx, true, verdict.Reason)
return agent.PermReviewResult{Allow: true, Reason: "autopilot: " + verdict.Reason}
},

StreamFirstChunkTimeout: streamFirstChunk,
StreamIdleTimeout: streamIdle,
}
}

Expand Down
18 changes: 18 additions & 0 deletions internal/setting/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,24 @@ func (s *Settings) SetSearchProvider(provider string) {
}
}

func (s *Settings) StreamFirstChunkTimeout() string {
s.mu.RLock()
defer s.mu.RUnlock()
if s.data == nil {
return ""
}
return s.data.StreamFirstChunkTimeout
}

func (s *Settings) StreamIdleTimeout() string {
s.mu.RLock()
defer s.mu.RUnlock()
if s.data == nil {
return ""
}
return s.data.StreamIdleTimeout
}

func (s *Settings) Hooks() map[string][]Hook {
s.mu.RLock()
defer s.mu.RUnlock()
Expand Down
9 changes: 9 additions & 0 deletions internal/setting/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ type Data struct {
Theme string `json:"theme,omitempty"`
SearchProvider string `json:"searchProvider,omitempty"`
AllowBypass *bool `json:"allowBypass,omitempty"`
// StreamFirstChunkTimeout overrides the core default (5m) for time-to-first-
// chunk. A valid time.Duration string (e.g. "5m", "120s"); empty = core default.
StreamFirstChunkTimeout string `json:"streamFirstChunkTimeout,omitempty"`
// StreamIdleTimeout overrides the core default (60s) for the gap between
// chunks once a stream has started. A valid time.Duration string (e.g. "60s",
// "120s"); empty = core default.
StreamIdleTimeout string `json:"streamIdleTimeout,omitempty"`
// ContextBar toggles the visual context-usage bar ([██████░░░░] 71%) in
// the status line. Pointer so an explicit "off" persists distinctly from
// "unset"; nil (unset) means off — the bar is opt-in. The numeric
Expand Down Expand Up @@ -537,6 +544,8 @@ func (s *Data) Clone() *Data {
dst.Model = s.Model
dst.Theme = s.Theme
dst.SearchProvider = s.SearchProvider
dst.StreamFirstChunkTimeout = s.StreamFirstChunkTimeout
dst.StreamIdleTimeout = s.StreamIdleTimeout
dst.Persona = s.Persona
dst.SelfLearn = s.SelfLearn // value-typed; shallow copy is correct
dst.AutoPilot = s.AutoPilot.Clone()
Expand Down