agent/new: fork_at= parameter for historical context forking

Sub-agents can now inherit parent context truncated at a specific
user turn: fork_at=5 means 'fork from the state after the 5th
user message.' Enables backtracking without losing unsummarized
context from before a failed approach.

Without fork_at (or fork_at=0), full history is inherited as before.
This commit is contained in:
Levi Neely 2026-08-17 09:04:57 +02:00
parent c34f4a1b31
commit ecb9406413
2 changed files with 30 additions and 7 deletions

View File

@ -345,13 +345,17 @@ func buildSessionChildren(
virtfs.FileNode("new", 0666, virtfs.FileNode("new", 0666,
virtfs.Doc("Create agent. With prompt=, runs as sub-agent: blocks until done, returns reply."), virtfs.Doc("Create agent. With prompt=, runs as sub-agent: blocks until done, returns reply."),
virtfs.Rdwr(func(ctx context.Context, data []byte) ([]byte, error) { virtfs.Rdwr(func(ctx context.Context, data []byte) ([]byte, error) {
params, prompt, parentID := parseAgentNewRequest(data) params, prompt, parentID, forkAt := parseAgentNewRequest(data)
params.ParentID = parentID params.ParentID = parentID
// Sub-agent inherits parent's context. // Sub-agent inherits parent's context (optionally truncated at fork_at).
if prompt != "" && parentID != "" { if prompt != "" && parentID != "" {
if parent := s.FindAgent(parentID); parent != nil { if parent := s.FindAgent(parentID); parent != nil {
params.History = agent.RestoreHistoryFromMessages(parent.Messages()) msgs := parent.Messages()
if forkAt > 0 {
msgs = messagesUpToTurn(msgs, forkAt)
}
params.History = agent.RestoreHistoryFromMessages(msgs)
} }
} }

View File

@ -9,6 +9,7 @@ import (
"sync" "sync"
"ollie/cmd/olliesrv/internal/agent" "ollie/cmd/olliesrv/internal/agent"
"ollie/cmd/olliesrv/internal/backend"
"ollie/cmd/olliesrv/internal/session" "ollie/cmd/olliesrv/internal/session"
"ollie/toolsrv/protocol" "ollie/toolsrv/protocol"
"ollie/virtfs" "ollie/virtfs"
@ -90,11 +91,11 @@ var (
// - Space-separated key=value (legacy format from GUI) // - Space-separated key=value (legacy format from GUI)
// //
// A bare word (no =) is treated as the profile name (legacy positional arg). // A bare word (no =) is treated as the profile name (legacy positional arg).
// Returns AgentParams, the prompt (empty if not a sub-agent request), and the parent agent ID. // Returns AgentParams, prompt, parent agent ID, and fork_at (user-turn index, 0=all).
func parseAgentNewRequest(data []byte) (session.AgentParams, string, string) { func parseAgentNewRequest(data []byte) (session.AgentParams, string, string, int) {
s := strings.TrimSpace(string(data)) s := strings.TrimSpace(string(data))
if s == "" { if s == "" {
return session.AgentParams{Profile: "default"}, "", "" return session.AgentParams{Profile: "default"}, "", "", 0
} }
var tokens []string var tokens []string
@ -106,6 +107,7 @@ func parseAgentNewRequest(data []byte) (session.AgentParams, string, string) {
p := session.AgentParams{Profile: "default"} p := session.AgentParams{Profile: "default"}
var prompt, parent string var prompt, parent string
var forkAt int
for _, tok := range tokens { for _, tok := range tokens {
tok = strings.TrimSpace(tok) tok = strings.TrimSpace(tok)
if tok == "" { if tok == "" {
@ -130,10 +132,27 @@ func parseAgentNewRequest(data []byte) (session.AgentParams, string, string) {
prompt = v prompt = v
case "parent": case "parent":
parent = v parent = v
case "fork_at":
fmt.Sscanf(v, "%d", &forkAt)
} }
} else if p.Profile == "default" { } else if p.Profile == "default" {
p.Profile = tok p.Profile = tok
} }
} }
return p, prompt, parent return p, prompt, parent, forkAt
}
// messagesUpToTurn returns messages up to (and including) the Nth user turn.
// Turn 1 = first user message and everything before the second user message.
func messagesUpToTurn(msgs []backend.Message, n int) []backend.Message {
userCount := 0
for i, m := range msgs {
if m.Role == "user" {
userCount++
if userCount > n {
return msgs[:i]
}
}
}
return msgs
} }