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
35 changes: 5 additions & 30 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import (
"github.com/enowdev/antares/internal/skills"
"github.com/enowdev/antares/internal/store"
"github.com/enowdev/antares/internal/tools"
"github.com/enowdev/antares/internal/worktree"
)

// EventType enumerates what the agent streams to callers.
Expand Down Expand Up @@ -403,7 +402,7 @@ func (a *Agent) Run(ctx context.Context, req Request, emit Emit) (*Result, error
}
}

history = a.maybeCompact(runCtx, history, systemPrompt, emit)
history = a.maybeCompact(runCtx, history, systemPrompt, modelName, toolSpecs, emit)

llmReq := llm.Request{
Model: modelName,
Expand Down Expand Up @@ -450,9 +449,9 @@ func (a *Agent) Run(ctx context.Context, req Request, emit Emit) (*Result, error
if resp.Usage.InputTokens > 0 || resp.Usage.OutputTokens > 0 {
_ = emit(Event{
Type: EventUsage, InputTokens: total.InputTokens, OutputTokens: total.OutputTokens,
// This turn's input (plus cached tokens, which still occupy the
// window) is the live context size the fill gauge plots.
ContextTokens: resp.Usage.InputTokens + resp.Usage.CacheReadTokens,
// Context size is provider-normalised: cache counters are billing
// breakdowns whose inclusion in InputTokens differs by API.
ContextTokens: resp.Usage.ContextSize(),
ContextWindow: a.contextWindowFor(modelName),
})
if !req.Quiet {
Expand Down Expand Up @@ -886,31 +885,7 @@ func (a *Agent) subAgentFor(parent Request) tools.SubAgent {
return a.runSubprocess(ctx, sub)
}

// Isolation gives the sub-agent its own worktree so parallel workers on
// the same repository do not conflict. When it cannot be set up, the
// sub-agent shares the workspace rather than failing.
workspace := sub.Workspace
// A sub-agent of a project session inherits the project: it works in the
// project folder and its writes stay confined there (plus the antares
// workspace), same as the parent. An isolated worktree opts out — it gets
// its own tree below.
projectDir := ""
if !sub.Isolate && strings.TrimSpace(parent.ProjectDir) != "" {
projectDir = parent.ProjectDir
if workspace == "" {
workspace = parent.ProjectDir
}
}
var wt *worktree.Worktree
if sub.Isolate {
base := firstNonEmpty(sub.Workspace, a.cfg.Agent.Workspace)
if w, err := worktree.Create(ctx, config.Expand(base), firstNonEmpty(sub.Role, "sub")); err == nil {
wt = w
workspace = w.Path
} else {
slog.Debug("worktree isolation unavailable", "error", err)
}
}
workspace, projectDir, wt := a.prepareSubAgentWorkspace(ctx, parent, sub)

subID, untrack := trackSubAgent(sub.Role, sub.Prompt, parent.SessionID)
defer untrack()
Expand Down
6 changes: 5 additions & 1 deletion internal/agent/background.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func (a *Agent) startBackground(parent Request, req tools.SubAgentRequest) strin
if maxTurns <= 0 {
maxTurns = 20
}
workspace := firstNonEmpty(req.Workspace, a.cfg.Agent.Workspace)
workspace, projectDir, wt := a.prepareSubAgentWorkspace(ctx, parent, req)

// Not Quiet: the sub-agent keeps a real session, so the task can be
// continued later with the task tool's send action. Every event is
Expand All @@ -109,11 +109,15 @@ func (a *Agent) startBackground(parent Request, req tools.SubAgentRequest) strin
Model: req.Model,
Role: req.Role,
Workspace: workspace,
ProjectDir: projectDir,
MaxTurns: maxTurns,
Platform: "background",
UserID: parent.UserID,
Depth: depth,
}, subEmit(subID, nil))
if wt != nil {
wt.Cleanup(ctx)
}

a.bg.finish(id, res, err, ctx.Err())
// Signal the delegating session that this worker is done, so the main
Expand Down
25 changes: 19 additions & 6 deletions internal/agent/compact.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package agent

import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
Expand Down Expand Up @@ -29,21 +30,18 @@ func (a *Agent) contextWindowFor(model string) int {

// maybeCompact summarises older turns once the conversation approaches the
// model's context window, keeping recent turns verbatim.
func (a *Agent) maybeCompact(ctx context.Context, history []llm.Message, system string, emit Emit) []llm.Message {
func (a *Agent) maybeCompact(ctx context.Context, history []llm.Message, system, model string, tools []llm.Tool, emit Emit) []llm.Message {
cfg := a.cfg.Compression
if !cfg.Enabled || len(history) < 8 {
return history
}
window := a.cfg.Model.ContextWindow
if window <= 0 {
window = 128000
}
window := a.contextWindowFor(model)
threshold := cfg.Threshold
if threshold <= 0 || threshold >= 1 {
threshold = 0.8
}

used := estimateTokens(history, system)
used := estimateRequestTokens(history, system, tools)
if float64(used) < float64(window)*threshold {
// Even below the threshold, prune oversized tool results that are no
// longer near the tail — they dominate context growth.
Expand Down Expand Up @@ -91,6 +89,21 @@ func (a *Agent) maybeCompact(ctx context.Context, history []llm.Message, system
return compacted
}

// estimateRequestTokens includes tool schemas as well as system/history. Large
// agent tool packs are sent on every call and can consume a material part of the
// context window; omitting them delays compaction until the provider rejects the
// request even though the history-only estimate still looks safe.
func estimateRequestTokens(history []llm.Message, system string, tools []llm.Tool) int {
total := estimateTokens(history, system)
for _, tool := range tools {
total += len(tool.Name)/4 + len(tool.Description)/4 + 8
if schema, err := json.Marshal(tool.Parameters); err == nil {
total += len(schema) / 4
}
}
return total
}

// rebalanceToolBoundary moves messages between middle and tail so that no tool
// result is separated from the assistant turn that requested it.
func rebalanceToolBoundary(middle, tail []llm.Message) ([]llm.Message, []llm.Message) {
Expand Down
72 changes: 72 additions & 0 deletions internal/agent/context_usage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package agent

import (
"encoding/json"
"testing"

"github.com/enowdev/antares/internal/config"
"github.com/enowdev/antares/internal/llm"
)

func TestContextWindowForPrefersActiveModelMetadata(t *testing.T) {
cfg := config.Default()
cfg.Model.ContextWindow = 200000
p := cfg.Providers["custom"]
p.ModelMeta = map[string]config.ModelMeta{
"cb-kimi-k3": {ContextWindow: 256000},
}
cfg.Providers["custom"] = p

a := &Agent{cfg: cfg}
if got := a.contextWindowFor("cb-kimi-k3"); got != 256000 {
t.Fatalf("contextWindowFor() = %d, want 256000", got)
}
}

func TestEstimateRequestTokensIncludesToolSchemas(t *testing.T) {
history := []llm.Message{{Role: llm.RoleUser, Content: "hello"}}
tools := []llm.Tool{{
Name: "large_tool",
Description: "tool description",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"payload": map[string]any{"type": "string", "description": string(make([]byte, 4000))},
},
},
}}

base := estimateTokens(history, "system")
got := estimateRequestTokens(history, "system", tools)
schema, err := json.Marshal(tools[0].Parameters)
if err != nil {
t.Fatal(err)
}
wantMinimum := base + len(tools[0].Name)/4 + len(tools[0].Description)/4 + 8 + len(schema)/4
if got < wantMinimum {
t.Fatalf("estimateRequestTokens() = %d, want at least %d", got, wantMinimum)
}
}

func TestUsageEventKeepsCumulativeAndLatestContextSeparate(t *testing.T) {
e := Event{
Type: EventUsage,
InputTokens: 333000,
ContextTokens: llm.Usage{InputTokens: 145878, CacheReadTokens: 145408, ContextTokens: 145878}.ContextSize(),
ContextWindow: 256000,
}
b, err := json.Marshal(e)
if err != nil {
t.Fatal(err)
}
var got map[string]any
if err := json.Unmarshal(b, &got); err != nil {
t.Fatal(err)
}
if got["input_tokens"] != float64(333000) {
t.Fatalf("input_tokens = %v", got["input_tokens"])
}
if got["context_tokens"] != float64(145878) {
t.Fatalf("context_tokens = %v; cached tokens were counted twice", got["context_tokens"])
}
}
42 changes: 42 additions & 0 deletions internal/agent/subagent_workspace.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package agent

import (
"context"
"log/slog"
"strings"

"github.com/enowdev/antares/internal/config"
"github.com/enowdev/antares/internal/tools"
"github.com/enowdev/antares/internal/worktree"
)

// prepareSubAgentWorkspace gives synchronous and background delegation identical
// workspace semantics. A worker inherits its parent's project by default. When
// isolation is requested, the worktree is created from that project (not from
// the global Antares workspace); failure falls back to the inherited project so
// the worker can still read and edit the files it was delegated.
func (a *Agent) prepareSubAgentWorkspace(ctx context.Context, parent Request, sub tools.SubAgentRequest) (workspace, projectDir string, wt *worktree.Worktree) {
workspace = strings.TrimSpace(sub.Workspace)
parentProject := strings.TrimSpace(parent.ProjectDir)

if parentProject != "" {
projectDir = parentProject
if workspace == "" {
workspace = parentProject
}
} else if workspace == "" {
workspace = firstNonEmpty(parent.Workspace, a.cfg.Agent.Workspace)
}

if !sub.Isolate {
return workspace, projectDir, nil
}

base := firstNonEmpty(sub.Workspace, parentProject, parent.Workspace, a.cfg.Agent.Workspace)
w, err := worktree.Create(ctx, config.Expand(base), firstNonEmpty(sub.Role, "sub"))
if err != nil {
slog.Warn("worktree isolation unavailable; using inherited workspace", "workspace", base, "error", err)
return workspace, projectDir, nil
}
return w.Path, "", w
}
91 changes: 91 additions & 0 deletions internal/agent/subagent_workspace_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package agent

import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"

"github.com/enowdev/antares/internal/config"
"github.com/enowdev/antares/internal/tools"
)

func TestPrepareSubAgentWorkspaceInheritsParentProject(t *testing.T) {
project := t.TempDir()
a := &Agent{cfg: &config.Config{Agent: config.Agent{Workspace: "/global/antares-workspace"}}}

workspace, projectDir, wt := a.prepareSubAgentWorkspace(
context.Background(),
Request{ProjectDir: project, Workspace: project},
tools.SubAgentRequest{},
)

if workspace != project {
t.Fatalf("workspace = %q, want inherited project %q", workspace, project)
}
if projectDir != project {
t.Fatalf("projectDir = %q, want %q", projectDir, project)
}
if wt != nil {
t.Fatal("non-isolated worker unexpectedly created a worktree")
}
}

func TestPrepareSubAgentWorkspaceIsolationFailureKeepsParentProject(t *testing.T) {
project := t.TempDir() // deliberately not a git repository
a := &Agent{cfg: &config.Config{Agent: config.Agent{Workspace: "/global/antares-workspace"}}}

workspace, projectDir, wt := a.prepareSubAgentWorkspace(
context.Background(),
Request{ProjectDir: project, Workspace: project},
tools.SubAgentRequest{Isolate: true, Role: "coder"},
)

if workspace != project || projectDir != project || wt != nil {
t.Fatalf("fallback = (%q, %q, %v), want inherited project without worktree", workspace, projectDir, wt)
}
}

func TestPrepareSubAgentWorkspaceIsolatesParentProject(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is not installed")
}
project := t.TempDir()
runGit := func(args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", project}, args...)...)
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.invalid",
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.invalid")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
}
runGit("init", "-q")
if err := os.WriteFile(filepath.Join(project, "README.md"), []byte("test\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit("add", "README.md")
runGit("commit", "-q", "-m", "initial")

a := &Agent{cfg: &config.Config{Agent: config.Agent{Workspace: "/global/antares-workspace"}}}
workspace, projectDir, wt := a.prepareSubAgentWorkspace(
context.Background(),
Request{ProjectDir: project, Workspace: project},
tools.SubAgentRequest{Isolate: true, Role: "coder"},
)
if wt == nil {
t.Fatal("isolation did not create a worktree from the parent project")
}
t.Cleanup(func() { wt.Cleanup(context.Background()) })
if workspace != wt.Path {
t.Fatalf("workspace = %q, want worktree %q", workspace, wt.Path)
}
if projectDir != "" {
t.Fatalf("isolated projectDir = %q, want empty", projectDir)
}
if _, err := os.Stat(filepath.Join(workspace, "README.md")); err != nil {
t.Fatalf("worktree does not contain parent project: %v", err)
}
}
4 changes: 4 additions & 0 deletions internal/llm/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ func (c *anthropicClient) fromResponse(raw *antResponse) (*Response, error) {
resp.Usage = Usage{
InputTokens: raw.Usage.InputTokens, OutputTokens: raw.Usage.OutputTokens,
CacheReadTokens: raw.Usage.CacheReadInputTokens, CacheWriteTokens: raw.Usage.CacheCreationInputTokens,
// Anthropic reports cache reads/creation alongside uncached input.
ContextTokens: raw.Usage.InputTokens + raw.Usage.CacheReadInputTokens + raw.Usage.CacheCreationInputTokens,
}
}
return resp, nil
Expand Down Expand Up @@ -340,6 +342,7 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request, emit func(Eve
usage.InputTokens = u.InputTokens
usage.CacheReadTokens = u.CacheReadInputTokens
usage.CacheWriteTokens = u.CacheCreationInputTokens
usage.ContextTokens = usage.InputTokens + usage.CacheReadTokens + usage.CacheWriteTokens
}
}
case "content_block_start":
Expand Down Expand Up @@ -381,6 +384,7 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request, emit func(Eve
if ev.Usage.CacheReadInputTokens > 0 {
usage.CacheReadTokens = ev.Usage.CacheReadInputTokens
}
usage.ContextTokens = usage.InputTokens + usage.CacheReadTokens + usage.CacheWriteTokens
u := usage
return emit(Event{Type: EventUsage, Usage: &u})
}
Expand Down
6 changes: 6 additions & 0 deletions internal/llm/anthropic_usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ func TestAnthropicStreamUsageFromMessageDelta(t *testing.T) {
if resp.Usage.CacheReadTokens != 2 {
t.Errorf("cache read tokens = %d, want 2", resp.Usage.CacheReadTokens)
}
if got, want := resp.Usage.ContextSize(), 15; got != want {
t.Errorf("context size = %d, want %d", got, want)
}
}

// TestAnthropicStreamUsageFromMessageStart guards the real-Anthropic path:
Expand Down Expand Up @@ -76,4 +79,7 @@ func TestAnthropicStreamUsageFromMessageStart(t *testing.T) {
if resp.Usage.CacheReadTokens != 5 {
t.Errorf("cache read tokens = %d, want 5", resp.Usage.CacheReadTokens)
}
if got, want := resp.Usage.ContextSize(), 105; got != want {
t.Errorf("context size = %d, want %d", got, want)
}
}
Loading