Skip to content
Open
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
104 changes: 103 additions & 1 deletion cmd/late/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ func main() {
enableSubagentsReq := flag.Bool("enable-subagents", true, "Enable subagent usage")
gemmaThinkingReq := flag.Bool("gemma-thinking", false, "Prepend <|think|> token to system prompt for Gemma 4 models")
subagentMaxTurns := flag.Int("subagent-max-turns", 500, "Maximum number of turns for subagents (default: 500)")
subagentTimeout := flag.Duration("subagent-timeout", appconfig.DefaultSubagentTimeout, "Max wall-clock time for one subagent run (0 = unlimited)")
subagentIdleTimeout := flag.Duration("subagent-idle-timeout", 15*time.Minute, "Notify when a subagent has been truly idle (no stream progress, no in-flight tool, no nested spawn) for this long (0 = off)")
subagentIdleKillAfter := flag.Duration("subagent-idle-kill-after", 0, "Kill a subagent that stays truly idle past this duration (0 = notify only)")
saveSubagentHistoriesReq := flag.Bool("save-subagent-histories", false, "Persist subagent conversation histories to disk (default: off)")
enableSqzReq := flag.Bool("enable-sqz", false, "Enable sqz context compression (if available)")
appendSystemPromptReq := flag.String("append-system-prompt", "", "Append text to the system prompt after processing")
Expand Down Expand Up @@ -125,6 +128,13 @@ func main() {
}
flag.Parse()

// Record which flags were explicitly passed on the command line. The app
// config loads AFTER flag.Parse below, so flag.Visit (which reports only
// command-line-set flags) is the only reliable "explicit flag > config"
// precedence signal for resolvers such as ResolveSubagentTimeout.
explicitFlags := map[string]bool{}
flag.Visit(func(f *flag.Flag) { explicitFlags[f.Name] = true })

tool.SetSqzEnabled(*enableSqzReq)

if *versionReq {
Expand Down Expand Up @@ -340,6 +350,16 @@ func main() {
}
}

// Resolve the global subagent run budget with precedence
// explicit --subagent-timeout flag > config.json "subagent_timeout" >
// DefaultSubagentTimeout (24h). A non-positive resolved budget ("0" or
// negative) means unlimited; an invalid config value is ignored with a
// warning and the default applies instead.
resolvedSubagentTimeout, subagentTimeoutWarning := appconfig.ResolveSubagentTimeout(appConfig, explicitFlags["subagent-timeout"], *subagentTimeout)
if subagentTimeoutWarning != "" {
fmt.Fprintf(os.Stderr, "Warning: %s\n", subagentTimeoutWarning)
}

// Parse explicit user logit bias overrides if provided
var explicitUserLogitBias map[string]int
if *logitBiasReq != "" {
Expand Down Expand Up @@ -524,6 +544,10 @@ func main() {
// Create root orchestrator
// We'll add middlewares later once the program is started
rootAgent := orchestrator.NewBaseOrchestrator(common.MainAgentID, sess, nil, 0)
// Idle watchdog policy applies to the root agent too: an orchestrator
// stuck with no stream progress, tool, or nested spawn reports idle (and,
// with --subagent-idle-kill-after, cancels its own run).
rootAgent.SetIdlePolicy(*subagentIdleTimeout, *subagentIdleKillAfter)

model := tui.NewModel(rootAgent, renderer, appConfig)
model.SetActiveThemeStyles(themeBytes)
Expand Down Expand Up @@ -674,7 +698,24 @@ func main() {
}()

if *enableSubagentsReq {
runner := func(ctx context.Context, goal string, ctxFiles []string, agentType string) (string, error) {
runner := func(ctx context.Context, goal string, ctxFiles []string, agentType string, timeoutOverride *time.Duration) (string, error) {
// Effective wall-clock budget for this run. Context layering:
// parent ctx (cancellation) ⊇ run budget (deadline) — runCtx is
// derived from ctx, so cancelling the parent still cancels the
// child while the budget only adds a deadline. Precedence:
// per-spawn override when positive > global resolved budget; an
// explicit per-spawn "0" (unlimited) suppresses the global
// budget; an absent override falls back to the global value.
// runBudget is kept in a local var so error classification can
// report e.g. "time budget exhausted (2h)".
runBudget := effectiveSubagentBudget(timeoutOverride, resolvedSubagentTimeout)
runCtx := ctx
var runCancel context.CancelFunc = func() {}
if runBudget > 0 {
runCtx, runCancel = context.WithTimeout(ctx, runBudget)
}
defer runCancel()

var currentSubagentClient *client.Client
if appConfig != nil {
if setting, ok := appConfig.GetModelForAgent(agentType); ok {
Expand Down Expand Up @@ -703,6 +744,52 @@ func main() {
}
child.SetMiddlewares(buildMiddlewares(pluginManager, p, child.Registry()))

// NewSubagentOrchestrator already set the child's context from
// the parent (agent.go: child.SetContext(parent.Context())) —
// keep that inheritance and layer the run budget on top: runCtx
// is derived from the runner ctx, so the parent ctx (cancellation)
// subsumes the budget (deadline). BaseOrchestrator.Execute then
// derives its run context from this one, so the deadline reaches
// the executor run loop.
if sa, ok := child.(interface{ SetContext(context.Context) }); ok {
sa.SetContext(runCtx)
}

// The child runs its own idle watchdog with the same policy as
// the parent (it is a BaseOrchestrator too), so a stuck nested
// agent reports idle — or kills itself — independently.
if sa, ok := child.(interface {
SetIdlePolicy(idle, killAfter time.Duration)
}); ok {
sa.SetIdlePolicy(*subagentIdleTimeout, *subagentIdleKillAfter)
}

// The child streams on its own session, so the parent shows no
// progress while the nested run executes. Keep the parent's
// activity alive with a 1/minute heartbeat and mark the nested
// spawn busy, so the parent's idle watchdog neither fires nor
// idle-kills while its child is legitimately working.
done := make(chan struct{})
defer close(done)
go func() {
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-done:
return
case <-t.C:
// rootAgent is a *orchestrator.BaseOrchestrator,
// which satisfies common.ActivityMarker; the parent
// stays visibly active while the child works.
rootAgent.MarkActivity()
}
}
}()
// Mark the nested spawn busy on the parent for the idle watchdog.
rootAgent.BeginNestedSpawn()
defer rootAgent.EndNestedSpawn()

res, err := child.Execute("")
if err != nil {
return "", err
Expand All @@ -726,6 +813,21 @@ func main() {
}
}

// effectiveSubagentBudget selects the wall-clock budget for one subagent run.
// Precedence: a positive per-spawn override wins; an explicit per-spawn "0"
// (unlimited) suppresses the global budget; an absent override falls back to
// the global budget. Any non-positive result means unlimited (no deadline) —
// callers guard with "> 0" before deriving a WithTimeout context.
func effectiveSubagentBudget(timeoutOverride *time.Duration, globalBudget time.Duration) time.Duration {
if timeoutOverride != nil {
if *timeoutOverride > 0 {
return *timeoutOverride
}
return 0 // explicit per-spawn unlimited suppresses the global budget
}
return globalBudget
}

// deriveEffectiveSessionID derives this run's session ID from the FINAL
// history path so resumed sessions keep their original ID. It returns ""
// for empty or unsafe results (a crafted meta file could claim an ID like
Expand Down
95 changes: 95 additions & 0 deletions cmd/late/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"encoding/json"
"flag"
"net/http"
"net/http/httptest"
"os"
Expand All @@ -17,6 +18,100 @@ import (
"late/internal/session"
)

// TestEffectiveSubagentBudget guards the runner's budget precedence:
// a positive per-spawn override wins; an explicit per-spawn "0" (unlimited)
// suppresses the global budget; an absent override falls back to the global
// budget; any non-positive result means unlimited.
func TestEffectiveSubagentBudget(t *testing.T) {
positive := 2 * time.Hour
unlimited := time.Duration(0)
global := 24 * time.Hour

tests := []struct {
name string
timeoutOverride *time.Duration
globalBudget time.Duration
want time.Duration
}{
{
name: "absent override falls back to the global budget",
timeoutOverride: nil,
globalBudget: global,
want: global,
},
{
name: "positive per-spawn override wins over the global budget",
timeoutOverride: &positive,
globalBudget: global,
want: positive,
},
{
name: "explicit per-spawn unlimited suppresses the global budget",
timeoutOverride: &unlimited,
globalBudget: global,
want: 0,
},
{
name: "per-spawn negative is unlimited",
timeoutOverride: &unlimited, // tool normalizes negatives to 0 before this point
globalBudget: global,
want: 0,
},
{
name: "absent override with unlimited global stays unlimited",
timeoutOverride: nil,
globalBudget: 0,
want: 0,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := effectiveSubagentBudget(tt.timeoutOverride, tt.globalBudget); got != tt.want {
t.Fatalf("effectiveSubagentBudget(%v, %v) = %v, want %v", tt.timeoutOverride, tt.globalBudget, got, tt.want)
}
})
}
}

// TestSubagentTimeoutFlagVisitDetection guards the precedence mechanism used
// in main(): config.json loads AFTER flag.Parse, so "explicit flag > config"
// resolution relies on flag.Visit reporting only command-line-set flags. A
// Duration flag must be reported when passed — even when its value equals the
// default — and never reported when absent.
func TestSubagentTimeoutFlagVisitDetection(t *testing.T) {
parseExplicit := func(t *testing.T, args []string) map[string]bool {
t.Helper()
fs := flag.NewFlagSet("late", flag.ContinueOnError)
fs.Duration("subagent-timeout", appconfig.DefaultSubagentTimeout, "Max wall-clock time for one subagent run (0 = unlimited)")
if err := fs.Parse(args); err != nil {
t.Fatalf("Parse(%v): %v", args, err)
}
explicit := map[string]bool{}
fs.Visit(func(f *flag.Flag) { explicit[f.Name] = true })
return explicit
}

tests := []struct {
name string
args []string
want bool
}{
{name: "flag passed is reported", args: []string{"-subagent-timeout=2h"}, want: true},
{name: "flag absent is not reported", args: nil, want: false},
{name: "flag passed at the default value is still reported", args: []string{"-subagent-timeout=24h"}, want: true},
{name: "flag passed as zero is still reported", args: []string{"-subagent-timeout=0"}, want: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parseExplicit(t, tt.args)["subagent-timeout"]; got != tt.want {
t.Fatalf(`explicit["subagent-timeout"] = %v, want %v`, got, tt.want)
}
})
}
}

// TestPluginInlineTool_RequiresConfirmation guards the documented contract
// that plugin inline tools (arbitrary scripts) go through the normal user
// confirmation flow. plugin-example.md: "user confirmation still prompts
Expand Down
16 changes: 16 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,22 @@ Approvals decay over time rather than becoming permanent trust forever.

---

## Subagent Control

Subagents run under wall-clock budgets and an idle watchdog:

| Flag | Default | Behavior |
| --- | --- | --- |
| `--subagent-timeout <dur>` | 24h | Max wall-clock time for one subagent run (`0` = unlimited). Also settable as `subagent_timeout` in `config.json`; the flag wins over the config value. |
| `--subagent-idle-timeout <dur>` | 15m | Notify when a subagent has been truly idle — no stream progress, no in-flight tool, no nested spawn (`0` = off). |
| `--subagent-idle-kill-after <dur>` | 0 | Kill a subagent that stays truly idle past this duration (`0` = notify only). |

The orchestrator can also budget a single run: `spawn_subagent` accepts an optional `timeout` argument (e.g. `"45m"`, `"2h"`; `"0"` = unlimited; omitted = the global value).

Every `bash` call accepts an optional per-call `timeout` argument (e.g. `"30m"`; `"0"` = unlimited; omitted = the global default of 10m). Timed-out processes are killed with their whole process group, so runaway pipes and grandchildren cannot hang the session.

---

## Run Fully Autonomously with Podman

For unattended work, large refactors, or overnight runs, use `late-podman`.
Expand Down
20 changes: 20 additions & 0 deletions internal/common/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"late/internal/client"
"time"
)

// ToolRunner defines the functional signature for executing a single tool call.
Expand Down Expand Up @@ -91,6 +92,25 @@ type MessageQueuedEvent struct {

func (e MessageQueuedEvent) OrchestratorID() string { return e.ID }

// SubagentIdleEvent is sent when an agent has been truly idle — no stream
// progress, no in-flight tool, no in-flight nested subagent — for longer
// than the configured idle threshold. Probe carries the last few transcript
// entries so the recipient can decide whether the agent is stuck.
type SubagentIdleEvent struct {
ID string
IdleFor time.Duration
Probe []string
}

func (e SubagentIdleEvent) OrchestratorID() string { return e.ID }

// ActivityMarker is implemented by orchestrators that track their own
// activity for the idle watchdog. Nested subagent runners call MarkActivity
// on the parent so it does not look idle while its child is working.
type ActivityMarker interface {
MarkActivity()
}

// PromptRequest defines a generic requirement for user input.
type PromptRequest struct {
ID string
Expand Down
35 changes: 35 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@ import (
"os"
"path/filepath"
"runtime"
"time"
)

const DefaultOpenAIBaseURL = "http://localhost:8080"

// DefaultSubagentTimeout is the default wall-clock budget for a single
// subagent run, applied when neither the --subagent-timeout flag nor the
// config.json "subagent_timeout" entry provides a value.
const DefaultSubagentTimeout = 24 * time.Hour

type EnvLookup func(string) (string, bool)

type OpenAISettings struct {
Expand Down Expand Up @@ -61,6 +67,13 @@ type Config struct {
// Enable via config file or the --save-subagent-histories CLI flag.
SaveSubagentHistories bool `json:"save_subagent_histories,omitempty"`

// SubagentTimeout is an optional wall-clock budget for a single subagent
// run, parsed with time.ParseDuration (e.g. "45m", "2h"). "0" or a
// negative value means unlimited. Empty means the built-in default
// (DefaultSubagentTimeout). Precedence: an explicitly passed
// --subagent-timeout flag wins over this entry.
SubagentTimeout string `json:"subagent_timeout,omitempty"`

// Legacy subagent fields for backward compatibility
SubagentBaseURL string `json:"subagent_base_url,omitempty"`
SubagentAPIKey string `json:"subagent_api_key,omitempty"`
Expand Down Expand Up @@ -241,6 +254,28 @@ func ResolveSaveSubagentHistories(cfg *Config, cliExplicit bool, cliValue bool,
return false
}

// ResolveSubagentTimeout resolves the global wall-clock budget for a single
// subagent run. Precedence: explicitly passed CLI flag > config.json
// "subagent_timeout" entry > DefaultSubagentTimeout (24h).
//
// A config value that parses via time.ParseDuration passes through as-is:
// "0" or a negative value means unlimited — callers guard with "> 0" and
// treat any non-positive budget as unlimited. An unparseable non-empty
// config value is ignored: the default is returned together with a warning
// for the caller to surface.
func ResolveSubagentTimeout(cfg *Config, cliExplicit bool, cliValue time.Duration) (time.Duration, string) {
if cliExplicit {
return cliValue, ""
}
if cfg != nil && cfg.SubagentTimeout != "" {
if parsed, err := time.ParseDuration(cfg.SubagentTimeout); err == nil {
return parsed, ""
}
return DefaultSubagentTimeout, fmt.Sprintf("ignoring invalid config.json subagent_timeout %q; using default 24h", cfg.SubagentTimeout)
}
return DefaultSubagentTimeout, ""
}

func nonEmptyEnv(lookup EnvLookup, key string) (string, bool) {
if lookup == nil {
return "", false
Expand Down
Loading
Loading