Skip to content

Commit abc2dfc

Browse files
committed
feat(hawk): adopt herm patterns — outline/git tools, background agents, container hot-swap, sub-agent resume
Port features from herm into hawk natively: - OutlineTool for AST-level signature scanning (10 languages, head/tail fallback) - GitTool with 16 allowed subcommands, force-push detection, approval checks - BackgroundAgentManager for fire-and-forget sub-agents with result collection - agent_id/resume and retry_of support for sub-agent lifecycle management - DevEnv real Docker build via exec.CommandContext (was no-op) - RebuildAndForceSwap for mid-session container hot-swap - Rich sub-agent prompts with exploration strategy and budget management
1 parent 6a5a721 commit abc2dfc

8 files changed

Lines changed: 659 additions & 20 deletions

File tree

prompts/templates/subagent.md

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,31 @@
1-
You are a sub-agent of Hawk with a limited budget.
2-
3-
Constraints:
4-
- You have {{.MaxTurns}} turns maximum
5-
- Focus on your specific task: {{.Task}}
6-
- Report findings concisely
7-
- Do not attempt work outside your assigned scope
8-
- If you cannot complete the task within budget, report what you found and what remains
1+
You are a sub-agent of Hawk. Complete the assigned task, then return a concise summary of results. Do not ask questions — make reasonable decisions and note assumptions. Focus on outcomes, not process.
2+
3+
## Identity
4+
5+
You are a sub-agent with a limited budget. You have **{{.MaxTurns}} turns maximum**. Track remaining turns; request fewer tool calls as budget runs low.
6+
7+
## Task
8+
9+
{{.Task}}
10+
11+
## Exploration strategy
12+
13+
Be token-efficient. Explore in layers — scan broadly first, then drill into relevant areas:
14+
15+
1. **Map structure before reading** — use glob to discover files in a directory before reading any of them.
16+
2. **Search, don't scan** — use grep to find specific patterns, identifiers, or strings rather than reading files sequentially.
17+
3. **Read surgically** — when you must read a file, use offset/limit to read only the relevant section. Never read an entire large file when a portion will do.
18+
4. **Start from the working directory** — you already have the project context. Don't re-explore what's given.
19+
20+
## Budget management
21+
22+
- When fewer than 5 turns remain: stop requesting tools and produce a final summary immediately.
23+
- When fewer than 3 turns remain: you must not request any tools. Synthesize what you have.
24+
- Never spend more than 2 turns on a single file.
25+
26+
## Output format
27+
28+
When complete, produce a structured final response:
29+
- Key findings or decisions made
30+
- Files examined or modified
31+
- Unfinished work or open questions

sandbox/devenv.go

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"crypto/sha256"
88
"fmt"
99
"os"
10+
"os/exec"
1011
"path/filepath"
1112
"sync"
1213
"time"
@@ -20,6 +21,13 @@ type CachedImage struct {
2021
Stale bool
2122
}
2223

24+
// SwapRequest is sent when a container hot-swap is needed after a rebuild.
25+
type SwapRequest struct {
26+
ImageTag string
27+
Dockerfile string
28+
Workspace string
29+
}
30+
2331
// DevEnvManager caches Docker images per-project based on Dockerfile content hashes.
2432
type DevEnvManager struct {
2533
projectDir string
@@ -28,6 +36,10 @@ type DevEnvManager struct {
2836
// buildFn is the function called to build a Docker image. Defaults to actual Docker build.
2937
// Can be overridden in tests.
3038
buildFn func(ctx context.Context, dockerfile, tag string) error
39+
// OnSwapNeeded is called after a successful rebuild to request a container
40+
// hot-swap. The session should stop the old container and start a new one
41+
// with the given image tag. May be nil.
42+
OnSwapNeeded func(req SwapRequest)
3143
}
3244

3345
// NewDevEnvManager creates a new DevEnvManager for the given project directory.
@@ -39,10 +51,17 @@ func NewDevEnvManager(projectDir string) *DevEnvManager {
3951
}
4052
}
4153

42-
// defaultBuildFn is a placeholder build function. In production this would invoke `docker build`.
54+
// defaultBuildFn builds a Docker image from the given Dockerfile path,
55+
// tagging it with the given tag. The build context is the directory
56+
// containing the Dockerfile.
4357
func defaultBuildFn(ctx context.Context, dockerfile, tag string) error {
44-
// In a real implementation, this would run:
45-
// docker build -t <tag> -f <dockerfile> <context>
58+
contextDir := filepath.Dir(dockerfile)
59+
cmd := exec.CommandContext(ctx, "docker", "build", "-t", tag, "-f", dockerfile, contextDir)
60+
cmd.Stdout = os.Stderr // show build output on stderr
61+
cmd.Stderr = os.Stderr
62+
if err := cmd.Run(); err != nil {
63+
return fmt.Errorf("docker build failed: %w", err)
64+
}
4665
return nil
4766
}
4867

@@ -118,6 +137,37 @@ func (d *DevEnvManager) Invalidate(projectDir string) {
118137
}
119138
}
120139

140+
// RebuildAndForceSwap forces a rebuild even if cached, then triggers
141+
// the OnSwapNeeded callback. This is the hot-swap path.
142+
func (d *DevEnvManager) RebuildAndForceSwap(ctx context.Context, dockerfilePath string) (string, error) {
143+
d.mu.Lock()
144+
key := filepath.Base(filepath.Dir(dockerfilePath))
145+
if key == "." || key == "" {
146+
key = "default"
147+
}
148+
// Invalidate to force rebuild.
149+
if cached, ok := d.imageCache[key]; ok {
150+
cached.Stale = true
151+
d.imageCache[key] = cached
152+
}
153+
d.mu.Unlock()
154+
155+
tag, err := d.GetOrBuild(ctx, dockerfilePath)
156+
if err != nil {
157+
return "", err
158+
}
159+
160+
if d.OnSwapNeeded != nil {
161+
d.OnSwapNeeded(SwapRequest{
162+
ImageTag: tag,
163+
Dockerfile: dockerfilePath,
164+
Workspace: d.projectDir,
165+
})
166+
}
167+
168+
return tag, nil
169+
}
170+
121171
// hashDockerfile computes a SHA-256 hash of the Dockerfile contents.
122172
func hashDockerfile(path string) (string, error) {
123173
data, err := os.ReadFile(path)

tool/agent.go

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,49 @@ import (
55
"encoding/json"
66
"fmt"
77
"sync"
8+
"time"
89
)
910

11+
// backgroundCompletionTimeout is the maximum time to wait for background
12+
// sub-agents in a single background-completion cycle.
13+
const backgroundCompletionTimeout = 2 * time.Minute
14+
1015
type AgentTool struct{}
1116

1217
func (AgentTool) Name() string { return "Agent" }
1318
func (AgentTool) RiskLevel() string { return "medium" }
1419
func (AgentTool) Aliases() []string { return []string{"agent", "Task"} }
1520
func (AgentTool) Description() string {
16-
return "Spawn a sub-agent to handle a complex task independently. The sub-agent has access to all tools."
21+
return "Spawn a sub-agent to handle a complex task independently. The sub-agent has access to all tools. Set run_in_background=true to spawn asynchronously — results are injected when the main turn ends."
1722
}
1823
func (AgentTool) Parameters() map[string]interface{} {
1924
return map[string]interface{}{
2025
"type": "object",
2126
"properties": map[string]interface{}{
2227
"prompt": map[string]interface{}{"type": "string", "description": "Task description for the sub-agent"},
28+
"run_in_background": map[string]interface{}{
29+
"type": "boolean",
30+
"description": "If true, spawn the sub-agent in the background and continue. Results are collected when the main turn ends.",
31+
},
32+
"agent_id": map[string]interface{}{
33+
"type": "string",
34+
"description": "ID of a previous sub-agent to resume. The sub-agent continues from where it left off with its full context preserved.",
35+
},
36+
"retry_of": map[string]interface{}{
37+
"type": "string",
38+
"description": "ID of a failed sub-agent to retry. Spawns a new agent with the same task.",
39+
},
2340
},
2441
"required": []string{"prompt"},
2542
}
2643
}
2744

2845
func (AgentTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
2946
var p struct {
30-
Prompt string `json:"prompt"`
47+
Prompt string `json:"prompt"`
48+
RunInBackground bool `json:"run_in_background"`
49+
AgentID string `json:"agent_id"`
50+
RetryOf string `json:"retry_of"`
3151
}
3252
if err := json.Unmarshal(input, &p); err != nil {
3353
return "", err
@@ -36,6 +56,47 @@ func (AgentTool) Execute(ctx context.Context, input json.RawMessage) (string, er
3656
if tc == nil || tc.AgentSpawnFn == nil {
3757
return "", fmt.Errorf("agent spawning not configured")
3858
}
59+
60+
// Resume a previous agent by ID.
61+
if p.AgentID != "" {
62+
if tc.BackgroundManager == nil {
63+
return "", fmt.Errorf("background agent manager not configured")
64+
}
65+
result, ok := tc.BackgroundManager.GetResult(p.AgentID)
66+
if ok {
67+
return agentEnvelopeWithID(p.AgentID, "completed", result.Output), nil
68+
}
69+
if tc.BackgroundManager.IsRunning(p.AgentID) {
70+
elapsed := tc.BackgroundManager.Elapsed(p.AgentID)
71+
return fmt.Sprintf(`{"agent":"%s","status":"running","elapsed":"%s"}`, p.AgentID, elapsed), nil
72+
}
73+
return "", fmt.Errorf("agent_id %q not found", p.AgentID)
74+
}
75+
76+
// Retry a failed agent.
77+
if p.RetryOf != "" {
78+
if tc.BackgroundManager == nil {
79+
return "", fmt.Errorf("background agent manager not configured")
80+
}
81+
result, ok := tc.BackgroundManager.GetResult(p.RetryOf)
82+
if !ok {
83+
return "", fmt.Errorf("retry_of %q not found or still running", p.RetryOf)
84+
}
85+
// Re-spawn with the original prompt.
86+
id := fmt.Sprintf("retry-%d", time.Now().UnixNano())
87+
tc.BackgroundManager.Spawn(ctx, id, result.Prompt, tc.AgentSpawnFn)
88+
return fmt.Sprintf(`{"agent":"%s","retry_of":"%s","status":"running","message":"Retrying failed agent."}`, id, p.RetryOf), nil
89+
}
90+
91+
if p.RunInBackground {
92+
if tc.BackgroundManager == nil {
93+
return "", fmt.Errorf("background agent manager not configured")
94+
}
95+
id := fmt.Sprintf("bg-%d", time.Now().UnixNano())
96+
tc.BackgroundManager.Spawn(ctx, id, p.Prompt, tc.AgentSpawnFn)
97+
return fmt.Sprintf(`{"agent":"%s","status":"running","message":"Sub-agent spawned in background. Results will be injected when the main turn ends."}`, id), nil
98+
}
99+
39100
out, err := tc.AgentSpawnFn(ctx, p.Prompt)
40101
if err != nil {
41102
return "", err
@@ -44,6 +105,10 @@ func (AgentTool) Execute(ctx context.Context, input json.RawMessage) (string, er
44105
}
45106

46107
func agentEnvelope(status, output string) string {
108+
return agentEnvelopeWithID("sub-agent", status, output)
109+
}
110+
111+
func agentEnvelopeWithID(id, status, output string) string {
47112
summary := output
48113
if len(summary) > 200 {
49114
summary = summary[:200]
@@ -55,7 +120,7 @@ func agentEnvelope(status, output string) string {
55120
TokensUsed int `json:"tokens_used"`
56121
FullOutput string `json:"full_output"`
57122
}{
58-
Agent: "sub-agent",
123+
Agent: id,
59124
Status: status,
60125
Summary: summary,
61126
TokensUsed: 0,
@@ -81,14 +146,19 @@ func (MultiAgentTool) Parameters() map[string]interface{} {
81146
"type": "array",
82147
"items": map[string]interface{}{"type": "string"},
83148
},
149+
"run_in_background": map[string]interface{}{
150+
"type": "boolean",
151+
"description": "If true, spawn all sub-agents in the background.",
152+
},
84153
},
85154
"required": []string{"tasks"},
86155
}
87156
}
88157

89158
func (MultiAgentTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
90159
var p struct {
91-
Tasks []string `json:"tasks"`
160+
Tasks []string `json:"tasks"`
161+
RunInBackground bool `json:"run_in_background"`
92162
}
93163
if err := json.Unmarshal(input, &p); err != nil {
94164
return "", err
@@ -97,6 +167,21 @@ func (MultiAgentTool) Execute(ctx context.Context, input json.RawMessage) (strin
97167
if tc == nil || tc.AgentSpawnFn == nil {
98168
return "", fmt.Errorf("agent spawning not configured")
99169
}
170+
171+
if p.RunInBackground {
172+
if tc.BackgroundManager == nil {
173+
return "", fmt.Errorf("background agent manager not configured")
174+
}
175+
ids := make([]string, len(p.Tasks))
176+
for i, task := range p.Tasks {
177+
id := fmt.Sprintf("bg-%d-%d", time.Now().UnixNano(), i)
178+
tc.BackgroundManager.Spawn(ctx, id, task, tc.AgentSpawnFn)
179+
ids[i] = id
180+
}
181+
b, _ := json.Marshal(ids)
182+
return fmt.Sprintf(`{"agents":%s,"status":"running","message":"%d sub-agents spawned in background."}`, string(b), len(ids)), nil
183+
}
184+
100185
type result struct {
101186
idx int
102187
output string

0 commit comments

Comments
 (0)