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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,9 @@ Features adopted from open-source agent projects. All are off by default unless
| Voice transcription | Telegram voice notes + `stt` package | Transcribe Telegram voice/audio into the prompt. Backend via `stt.SetTranscriber`; an OpenAI-compatible client ships in `eyrie/client` (`AudioClient`), wired by the host |
| Git-tree file snapshots | `internal/gitsnapshot` | Content-addressed tree capture/diff/preview/restore |
| Turn-boundary rewind | `internal/filestate` | Per-prompt before/after snapshots with durable store |
| Continual harness | `internal/intelligence/harness` | Versioned, evidence-backed refinement of supplemental prompts/memories/skills/subagents with rollback |
| Bounded autonomous budgets | `internal/engine` (`AutonomousBudget`) | Track turns/tokens/time/continuations; report why a run stopped (budget vs gate-passed vs error) |
| Agent family messaging | `internal/multiagent` (`FamilyMessenger`) | Direct parent/sibling/child messages with pending caps + rate limits |
| Path reservations | `internal/multiagent` ledger | Detect overlapping-file changes between parallel branches |
| Live agent status | `GET /v1/agent/status` (daemon) | Machine-readable working/idle/stale per session |

Expand Down
121 changes: 121 additions & 0 deletions internal/engine/autonomous_budget.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package engine

import (
"time"
)

// Bounded autonomous budget tracking, adopted from Prime Agent's autonomous
// mode: the agent runs within configured turn/token/time/continuation budgets
// and a status machine reports WHY a run stopped — budget_limited (with the
// specific budget hit) vs gate_passed vs error — so completion is honest
// ("a passed gate checks only what that gate verifies; reaching a limit does
// not imply task success").

// AutonomousReason classifies why a bounded run stopped.
type AutonomousReason string

const (
ReasonRunning AutonomousReason = "running"
ReasonBudgetTokens AutonomousReason = "budget_limited:tokens"
ReasonBudgetTurns AutonomousReason = "budget_limited:turns"
ReasonBudgetTime AutonomousReason = "budget_limited:time"
ReasonBudgetConcurr AutonomousReason = "budget_limited:continuations"
ReasonGatePassed AutonomousReason = "gate_passed"
ReasonError AutonomousReason = "error"
)

// AutonomousLimits are the configured bounds.
type AutonomousLimits struct {
MaxTurns int `json:"max_turns,omitempty"` // 0 = unlimited
MaxTokens int `json:"max_tokens,omitempty"` // 0 = unlimited
MaxTime time.Duration `json:"max_time,omitempty"` // 0 = unlimited
MaxContinuations int `json:"max_continuations,omitempty"` // 0 = unlimited
}

// AutonomousStatus is the live tracking state.
type AutonomousStatus struct {
Limits AutonomousLimits `json:"limits"`
TurnsUsed int `json:"turns_used"`
TokensUsed int `json:"tokens_used"`
TimeUsedSeconds float64 `json:"time_used_seconds"`
ContinuationsUsed int `json:"continuations_used"`
Reason AutonomousReason `json:"reason"`
LastError string `json:"last_error,omitempty"`
StartedAt time.Time `json:"started_at"`
}

// AutonomousBudget tracks a bounded autonomous run.
type AutonomousBudget struct {
limits AutonomousLimits
status AutonomousStatus
started time.Time
}

// NewAutonomousBudget starts a bounded run with the given limits.
func NewAutonomousBudget(limits AutonomousLimits) *AutonomousBudget {
return &AutonomousBudget{
limits: limits,
status: AutonomousStatus{Limits: limits, Reason: ReasonRunning, StartedAt: time.Now()},
started: time.Now(),
}
}

// RecordTurn increments the turn counter and reports the current reason.
func (b *AutonomousBudget) RecordTurn() AutonomousReason {
b.status.TurnsUsed++
return b.Check()
}

// RecordToken adds usage and reports the current reason.
func (b *AutonomousBudget) RecordToken(n int) AutonomousReason {
b.status.TokensUsed += n
return b.Check()
}

// RecordContinuation increments the continuation counter.
func (b *AutonomousBudget) RecordContinuation() AutonomousReason {
b.status.ContinuationsUsed++
return b.Check()
}

// Check recomputes the status from budgets + elapsed time. Once a terminal
// reason is reached it stays terminal (first budget hit wins).
func (b *AutonomousBudget) Check() AutonomousReason {
if b.status.Reason != ReasonRunning {
return b.status.Reason
}
b.status.TimeUsedSeconds = time.Since(b.started).Seconds()
switch {
case b.limits.MaxTurns > 0 && b.status.TurnsUsed >= b.limits.MaxTurns:
b.status.Reason = ReasonBudgetTurns
case b.limits.MaxTokens > 0 && b.status.TokensUsed >= b.limits.MaxTokens:
b.status.Reason = ReasonBudgetTokens
case b.limits.MaxContinuations > 0 && b.status.ContinuationsUsed >= b.limits.MaxContinuations:
b.status.Reason = ReasonBudgetConcurr
case b.limits.MaxTime > 0 && time.Since(b.started) >= b.limits.MaxTime:
b.status.Reason = ReasonBudgetTime
}
return b.status.Reason
}

// MarkGatePassed records that a configured quality gate succeeded.
func (b *AutonomousBudget) MarkGatePassed() {
if b.status.Reason == ReasonRunning {
b.status.Reason = ReasonGatePassed
}
}

// MarkError records a run error.
func (b *AutonomousBudget) MarkError(errMsg string) {
b.status.Reason = ReasonError
b.status.LastError = errMsg
}

// Status returns a copy of the live status.
func (b *AutonomousBudget) Status() AutonomousStatus { return b.status }

// Running reports whether the run should continue (not terminal).
func (b *AutonomousBudget) Running() bool { return b.status.Reason == ReasonRunning }

// StopReason returns the terminal reason, or ReasonRunning if still running.
func (b *AutonomousBudget) StopReason() AutonomousReason { return b.status.Reason }
87 changes: 87 additions & 0 deletions internal/engine/autonomous_budget_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package engine

import (
"testing"
"time"
)

func TestBudgetTurnLimit(t *testing.T) {
b := NewAutonomousBudget(AutonomousLimits{MaxTurns: 3})
if b.Running() != true {
t.Fatal("should start running")
}
b.RecordTurn()
b.RecordTurn()
if r := b.RecordTurn(); r != ReasonBudgetTurns {
t.Fatalf("reason = %q, want budget_limited:turns", r)
}
if b.Running() {
t.Fatal("should be stopped")
}
if s := b.Status(); s.TurnsUsed != 3 || s.Reason != ReasonBudgetTurns {
t.Fatalf("status = %+v", s)
}
}

func TestBudgetTokenLimit(t *testing.T) {
b := NewAutonomousBudget(AutonomousLimits{MaxTokens: 1000})
b.RecordToken(600)
if r := b.RecordToken(500); r != ReasonBudgetTokens {
t.Fatalf("reason = %q, want budget_limited:tokens", r)
}
}

func TestBudgetTimeLimit(t *testing.T) {
b := NewAutonomousBudget(AutonomousLimits{MaxTime: 20 * time.Millisecond})
time.Sleep(30 * time.Millisecond)
if r := b.Check(); r != ReasonBudgetTime {
t.Fatalf("reason = %q, want budget_limited:time", r)
}
}

func TestGatePassedBeatsBudgetsOnlyIfRunning(t *testing.T) {
b := NewAutonomousBudget(AutonomousLimits{MaxTurns: 2})
b.RecordTurn()
b.MarkGatePassed()
if b.StopReason() != ReasonGatePassed {
t.Fatalf("reason = %q, want gate_passed", b.StopReason())
}
// After a terminal reason, budgets do not override it.
b.RecordTurn()
if b.StopReason() != ReasonGatePassed {
t.Fatalf("terminal should stick, got %q", b.StopReason())
}
}

func TestErrorSticks(t *testing.T) {
b := NewAutonomousBudget(AutonomousLimits{MaxTurns: 10})
b.MarkError("boom")
if b.StopReason() != ReasonError || b.Status().LastError != "boom" {
t.Fatalf("status = %+v", b.Status())
}
if b.Running() {
t.Fatal("should stop on error")
}
}

func TestFirstBudgetHitWins(t *testing.T) {
b := NewAutonomousBudget(AutonomousLimits{MaxTurns: 2, MaxTokens: 100})
b.RecordTurn()
b.RecordToken(120) // tokens hit first -> terminal reason sticks
b.RecordTurn() // turns now also exceeded, but reason is already terminal
if b.StopReason() != ReasonBudgetTokens {
t.Fatalf("reason = %q, want budget_limited:tokens (first hit wins)", b.StopReason())
}
}

func TestUnlimitedRun(t *testing.T) {
b := NewAutonomousBudget(AutonomousLimits{})
for i := 0; i < 100; i++ {
b.RecordTurn()
b.RecordToken(1)
b.RecordContinuation()
}
if !b.Running() || b.StopReason() != ReasonRunning {
t.Fatalf("unlimited run should keep running, got %q", b.StopReason())
}
}
Loading
Loading