Repository navigation
Expand file tree
/
Copy pathbuilder.go
More file actions
526 lines (506 loc) · 24.7 KB
/
Copy pathbuilder.go
File metadata and controls
526 lines (506 loc) · 24.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
package agent
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/dmmdea/offload-harness/internal/core"
"time"
"github.com/dmmdea/offload-harness/internal/sandbox"
)
// BuildConfig declares everything a drive mode needs to construct the agent loop
// identically. The caller supplies the local planner endpoint, the read scope, an
// in-process offload closure (record=false — e.g. pipeline.NewRecordlessOffload),
// and which capabilities are opt-in enabled. Build wires the tools + the single
// deny→ask→allow broker + the loop the SAME way for every mode (CLI, MCP front
// door, standalone), so the three modes cannot drift.
type BuildConfig struct {
PlannerBase string // local model base URL (no /v1); required
Model string // planner model id (must support tool-calling); required
Timeout time.Duration // per planner-call timeout; default 180s
MaxSteps int // hard step budget; default 12
MaxTokens int // planner max tokens per call; default 1024
MaxSameTool int // per-run cap on calls to any one tool name; 0 => Loop's default (8); <0 => disabled
ReadRoot string // directory the agent may read (P0 scope); required
Offload OffloadFunc // in-process offload (record=false); nil => no offload tools
NPU NPUFunc // in-process accelerator lane (pipeline.NewLoopNPU); nil => no NPU tools
Accel []AccelLane // every accelerator lane in config order (pipeline.NewLoopAccel); when set it supersedes NPU
// SystemPromptOverride, when set, replaces the capability-aware system prompt
// (P6 flywheel replay evaluates a CANDIDATE planner prompt). Empty => the normal
// SystemPrompt. The tool set is still capability-gated as usual, so a read-only
// build stays side-effect-free regardless of the prompt.
SystemPromptOverride string
// Unattended: true => every broker "ask" deny-and-queues (no human in the
// loop). Also the key the default rule table loads on (see RulesPath): a
// future caller granting mutating capability WITHOUT setting Unattended
// gets no default table — set this honestly, not as a UI preference.
Unattended bool
AuditPath string // append-only broker audit JSONL; must live OUTSIDE the worktree
AskQueuePath string // P5b: reviewable queue of asks deferred on an unattended run (optional)
// AuditAdvisory (register SF-02, audit_all_doors=warn): a failed audit write is
// reported once and never turns an allow into a deny. Ignored on a browse run,
// whose grant requires an enforcing trail.
AuditAdvisory bool
// AuditChain (register SF-08, config audit_chain): hash-chain this run's rows on the
// trail (run_id, seq, prev_sha256) and close the run with EndAudit.
AuditChain bool
// EvidenceReducer (config agent_evidence_reducer) installs the evidence-preserving
// reducer for build and test results over Offload, the cascade seam. Off by
// default; with no Offload it cannot be installed and Build says so in Notes.
EvidenceReducer bool
// AuditEnforcedByKey (SF-02): the trail is enforcing because audit_all_doors=enforce
// asked for it; a denial it causes then names the key and the way out.
AuditEnforcedByKey bool
// ReadFloor (register SF-07, config agent_read_floor) is the read floor's mode for
// every read tool: "" or "warn" (the default) records reads of secret material,
// "enforce" refuses them, "off" checks nothing. --rules off also switches it off.
ReadFloor string
// RulesPath names the structural risk table (rules.go LoadRules); tighten-only.
// Empty on an UNATTENDED run loads the embedded default table
// (unattendedrules.go); the sentinel RulesOff ("off") explicitly disables it.
RulesPath string
// EnvRules is the seat's environment-rule table (core.AgentEnvRules,
// config `agent_env_rules`; envrules.go, ADR 0036). nil/zero = no hooks.
// Compiled here so an invalid table fails the build by name at every door
// (CLI exit, MCP defer, fleet defer) instead of no-op'ing at run time.
EnvRules *core.AgentEnvRules
// SetupActions (setup.go, ADR 0036 P2): tool calls replayed before the
// model's first turn. Already validated at the door (core.Validate);
// whether a tool exists on this seat is answered by the loop as an
// observation. nil = no replay.
SetupActions []core.AgentSetupAction
// Sampling / SamplingFinal are the seat's decoding policy (sampling.go,
// register D-95b): the planner policy for tool steps and the optional
// separate policy for the (thinking-off) final answer. Both nil is the
// request this client has always sent — temperature 0 and nothing else.
Sampling *core.AgentSampling
SamplingFinal *core.AgentSampling
// Thinking is the planner think-block policy (thinking.go ThinkingMode);
// "" = ThinkingAuto. Validated by the caller (core.ValidateThinking /
// ParseThinkingMode) — an unparseable value fails the build by name.
Thinking string
// StreamTokenIDs asks the seat for its per-token progress signal on the
// streamed planner calls (LLMClient.WithStreamTokenIDs). Set ONLY for a
// seat this box declares as vLLM: its tool parser holds some tool-call
// arguments back and the stall watch would otherwise see silence.
StreamTokenIDs bool
AllowWrite bool // P2: write_file/delete_file in the worktree
// WriteLimit caps how many files and bytes ONE run may write (the D-06
// delegation write door). nil = uncapped, which is what the operator's own
// CLI worktree has always been; the delegation door always sets one.
WriteLimit *WriteLimit
AllowFetch bool // P3: web_fetch behind the egress allowlist
AllowShell bool // P4.6: run_shell in the LINUX OS cage (granted only on Linux + sandbox.Available)
AllowRun bool // C7b: `run` — allowlisted direct-exec runner in the OS sandbox (Linux AND Windows)
AllowOverwrite bool // open-write: allow overwrite of existing files in the worktree
AllowDelete bool // open-write: allow delete of files in the worktree
AllowSearch bool // web_search (DuckDuckGo); auto-allowlists the search host
AllowGitHub bool // github_api/create_repo/upload_file; auto-allowlists api.github.com
GitHubToken string // token for the GitHub tools (secret; Authorization header only)
GitHubRepo string // default OWNER/NAME for github_upload_file
Worktree string // RW worktree for write/shell; default = ReadRoot
EgressHosts []string // web_fetch allowlist (AllowFetch)
// AllowBrowse grants the `browse` tool (ADR 0060): drive the operator's own
// browser through the browse lane. Granted only when Browse is set (the door has
// a configured lane), an AuditPath exists (every run leaves a trail) and, on an
// UNATTENDED run, BrowseHosts is non-empty. BrowseTimeout bounds one call (the
// lane's own timeout plus slack; 0 => 6 minutes).
AllowBrowse bool
BrowseHosts []string
Browse BrowseFunc
BrowseTimeout time.Duration
// BrowseAgentDoor marks a grant that came from an agent door (agent_run,
// agent_delegate, a fleet contract): browse is then judged UNATTENDED — host
// list required, deny-list never lifted — whatever Unattended says, because
// no human watches that run's browser.
BrowseAgentDoor bool
Memory Memory // optional mem0 layer; nil => no memory
}
// BuildResult is the assembled loop plus what was actually granted. ShellGranted
// is false when --allow-shell was requested but the OS cage is unavailable here
// (fail-closed); Notes are human-readable capability lines the caller can log.
type BuildResult struct {
Loop *Loop
Tools []Tool
Policy *Policy
Worktree string // resolved RW worktree (empty if no write/shell/run)
ShellGranted bool
RunGranted bool
BrowseGranted bool
Notes []string
// Audit is the run's trail (nil when none is attached); EndAudit closes a chained run.
Audit *AuditLog
}
// Build assembles the agent loop for any drive mode. It is the SINGLE place the
// tool set + broker + loop are constructed, so the CLI, the MCP front door, and
// the standalone runner stay at parity by construction. Operational failures
// (bad paths, audit-inside-worktree, worktree creation) are returned as errors,
// never os.Exit — the caller decides how to surface them.
func Build(cfg BuildConfig) (*BuildResult, error) {
if cfg.PlannerBase == "" || cfg.Model == "" {
return nil, fmt.Errorf("agent.Build: PlannerBase and Model are required")
}
if cfg.ReadRoot == "" {
return nil, fmt.Errorf("agent.Build: ReadRoot is required")
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 180 * time.Second
}
maxSteps := cfg.MaxSteps
if maxSteps <= 0 {
maxSteps = 12
}
maxTokens := cfg.MaxTokens
if maxTokens <= 0 {
maxTokens = 1024
}
absRoot, err := filepath.Abs(cfg.ReadRoot)
if err != nil {
return nil, fmt.Errorf("bad ReadRoot %q: %w", cfg.ReadRoot, err)
}
floorMode, ferr := validReadFloor(cfg.ReadFloor)
if ferr != nil {
return nil, ferr
}
requestedFloor := floorMode
if cfg.RulesPath == RulesOff {
floorMode = "off" // the documented off switch for the whole tighten-only table
}
gate := &readGate{mode: floorMode}
tools, err := readOnlyTools(absRoot, cfg.Offload, cfg.NPU, cfg.Accel, gate)
if err != nil {
return nil, fmt.Errorf("building read tools: %w", err)
}
res := &BuildResult{}
// The single broker governs write+fetch+search+github+shell. The egress
// allowlist is built when ANY network tool is enabled; enabling web_search or
// github auto-allowlists their hosts so the capability flag is self-sufficient.
var allow Allowlist
if cfg.AllowFetch || cfg.AllowSearch || cfg.AllowGitHub {
hosts := append([]string{}, cfg.EgressHosts...)
if cfg.AllowSearch {
hosts = append(hosts, "html.duckduckgo.com", "duckduckgo.com", "lite.duckduckgo.com")
}
if cfg.AllowGitHub {
hosts = append(hosts, "api.github.com")
}
a, aerr := NewAllowlist(hosts)
if aerr != nil {
return nil, fmt.Errorf("bad egress host: %w", aerr)
}
allow = a
if cfg.AllowFetch && len(cfg.EgressHosts) == 0 {
res.Notes = append(res.Notes, "web_fetch ON but egress allowlist EMPTY — web_fetch will refuse every URL (search/github hosts are auto-allowlisted separately)")
}
}
var audit *AuditLog
if cfg.AuditPath != "" {
audit = NewAuditLog(cfg.AuditPath)
if cfg.AuditChain {
audit.WithChain(newRunID())
}
}
res.Audit = audit
pol := NewPolicyWithEgress(cfg.Unattended, audit, allow)
// Warn mode (SF-02) never applies to a browse run: its grant requires an
// enforcing trail (ADR 0060), so asking for browse keeps the trail enforcing.
if audit != nil && cfg.AuditAdvisory && !cfg.AllowBrowse {
pol.WithAuditAdvisory(true)
}
if audit != nil && cfg.AuditEnforcedByKey {
pol.WithAuditEnforcedByKey(true)
}
switch {
case cfg.RulesPath == RulesOff:
// Explicit escape hatch: the operator opted out of the default unattended
// table. MEASURED 2026-08-11: the model's own `security_risk` annotation
// was a literal constant "low" — 83/83 emitted declarations across two
// production seats, including 81/81 structurally destructive calls — so an
// ungated unattended run's only per-call gate above the capability flags
// has 0% recall. The built-in defaultRules() secret-material floor would
// not have stopped any of them. Saying yes to that is the operator's
// right; running that way without having said so is not, which is why
// this branch exists only behind the explicit sentinel.
// AllowWrite is in this set deliberately (review finding 2026-08-14):
// the default table gates write-new paths too (CI workflows deny,
// config writes queue), so opting out with write-only capability is a
// real policy downgrade and must not be silent.
if cfg.Unattended && (cfg.AllowWrite || cfg.AllowDelete || cfg.AllowOverwrite || cfg.AllowShell || cfg.AllowGitHub) {
res.Notes = append(res.Notes,
"UNGATED (--rules off): unattended run with mutating capability and no rule table. "+
"The model's own security_risk annotation is then the only per-call gate, "+
"and it measured as a constant 'low' (0% recall on destructive calls).")
}
case cfg.RulesPath != "":
rs, rerr := LoadRules(cfg.RulesPath)
if rerr != nil {
// Fail closed: an operator who pointed at a rule table believes it is
// active. Running without it would be a silent policy downgrade.
return nil, fmt.Errorf("risk rule table: %w", rerr)
}
if _, rerr = pol.WithRules(rs); rerr != nil {
return nil, fmt.Errorf("risk rule table: %w", rerr)
}
case cfg.Unattended:
// No table named on an UNATTENDED run: load the embedded default
// (unattendedrules.go — deletes and config/manifest writes queue for
// review; evidence, weights, workflows and lockfiles deny). This replaces
// the 0.48.0-era UNGATED warning with the gate itself: warning an absent
// operator is exactly the mechanism the measurement showed does not work.
// `--rules <path>` replaces this table; `--rules off` disables it.
rs, rerr := UnattendedRules()
if rerr != nil {
// Cannot happen with the tested embed, but fail closed on principle:
// a default gate that silently fails to load is a policy downgrade.
return nil, fmt.Errorf("built-in unattended rule table: %w", rerr)
}
if _, rerr = pol.WithRules(rs); rerr != nil {
return nil, fmt.Errorf("built-in unattended rule table: %w", rerr)
}
// Announce only when AllowWrite grants the tools the table actually
// gates (write_file/edit_file/delete_file → ActWrite/ActDelete). The
// table has no fetch rules by design and CANNOT see shell/run file
// operations (the OS cage owns those), so announcing it on a
// fetch/shell/run-only run would be false assurance (review finding
// 2026-08-14).
if cfg.AllowWrite {
res.Notes = append(res.Notes, fmt.Sprintf(
"default unattended rule table ACTIVE (%d rules, write/delete tools only — shell/run "+
"file ops are governed by the OS cage, not this table): deletes and config/manifest "+
"writes queue for review; evidence/weights/workflow mutations and lockfile hand-edits "+
"deny. --rules <path> replaces it; --rules off disables it (see internal/agent/unattended-rules.json).", len(rs)))
}
}
// Bind the read floor (SF-07) to the trail and to the table's read rules now
// that both exist; the read tools built above hold the same gate.
gate.audit = audit
gate.strict = audit != nil && !pol.auditAdvisory // an enforcing trail refuses a read it cannot record
pol.readGate = gate // the write and GitHub tools built below share it
for _, r := range pol.rules {
if r.Kind == ActRead {
gate.rules = append(gate.rules, r)
}
}
switch {
case cfg.RulesPath == RulesOff && requestedFloor != "off":
res.Notes = append(res.Notes, "read floor OFF: --rules off disables it with the rest of the table (agent_read_floor="+requestedFloor+" is not applied)")
case floorMode == "enforce":
note := "read floor ENFORCED: reads of secret material (.env*, keys and certificates, .ssh/.aws/.gnupg/.kube, .npmrc/.netrc/.pypirc, .git-credentials) and the table's read rules are refused by read_file, summarize_file, search_files, edit_file and github_upload_file"
if cfg.AllowRun || cfg.AllowShell {
note += "; NOT by the run/shell cage, whose allowlisted interpreters can read any file they reach"
}
res.Notes = append(res.Notes, note)
case floorMode == "warn" && len(gate.rules) > 0:
res.Notes = append(res.Notes, fmt.Sprintf("%d read rule(s) loaded: under agent_read_floor=warn a read they catch is recorded, not refused", len(gate.rules)))
}
var askQueue *AuditLog
if cfg.AskQueuePath != "" {
askQueue = NewAuditLog(cfg.AskQueuePath)
pol.WithAskQueue(askQueue)
}
pol.WithWritePosture(cfg.AllowOverwrite, cfg.AllowDelete)
res.Policy = pol
// The RW worktree is shared by write_file/delete_file (P2), run_shell (P4.6),
// the `run` runner (C7b), and github_upload_file (which reads the file it uploads
// from the worktree).
var absWt string
if cfg.AllowWrite || cfg.AllowShell || cfg.AllowRun || cfg.AllowGitHub {
wt := cfg.Worktree
if wt == "" {
wt = absRoot
}
if absWt, err = filepath.Abs(wt); err != nil {
return nil, fmt.Errorf("bad Worktree %q: %w", wt, err)
}
if err := os.MkdirAll(absWt, 0o755); err != nil {
return nil, fmt.Errorf("creating worktree %q: %w", absWt, err)
}
// The audit trail AND the ask-queue must live OUTSIDE the worktree, or
// write_file / run_shell could clobber the integrity records via the cage path.
for _, p := range []struct{ name, path string }{{"audit", cfg.AuditPath}, {"ask-queue", cfg.AskQueuePath}} {
if p.path == "" {
continue
}
if apAbs, e := filepath.Abs(p.path); e == nil {
if rel, e2 := filepath.Rel(absWt, apAbs); e2 == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return nil, fmt.Errorf("%s path %q is inside the worktree %q (the agent could clobber it); choose a path outside it", p.name, p.path, absWt)
}
}
}
res.Worktree = absWt
}
if cfg.AllowWrite {
wtools, terr := WriteToolsLimited(absWt, pol, cfg.WriteLimit)
if terr != nil {
return nil, fmt.Errorf("building write tools: %w", terr)
}
tools = append(tools, wtools...)
posture := "new files only; overwrite/delete refused"
switch {
case cfg.AllowOverwrite && cfg.AllowDelete:
posture = "OPEN — create/overwrite/delete within the worktree"
case cfg.AllowOverwrite:
posture = "create/overwrite within the worktree; delete refused"
}
if cfg.WriteLimit != nil {
posture += fmt.Sprintf("; capped at %d files / %d bytes for this run", cfg.WriteLimit.maxFiles, cfg.WriteLimit.maxBytes)
}
res.Notes = append(res.Notes, fmt.Sprintf("write ON — worktree=%s (%s)", absWt, posture))
}
if cfg.AllowFetch {
tools = append(tools, FetchTools(pol)...)
res.Notes = append(res.Notes, fmt.Sprintf("egress ON — allowlist=%v (only allowlisted hosts; loopback/private/redirect-escape blocked)", cfg.EgressHosts))
}
if cfg.AllowSearch {
tools = append(tools, SearchTools(pol)...)
res.Notes = append(res.Notes, "web_search ON (DuckDuckGo; results are UNTRUSTED third-party data)")
}
if cfg.AllowGitHub {
tools = append(tools, GitHubTools(pol, cfg.GitHubToken, cfg.GitHubRepo, absWt)...)
tokState := "TOKEN SET"
if strings.TrimSpace(cfg.GitHubToken) == "" {
tokState = "NO TOKEN — github tools will refuse"
}
res.Notes = append(res.Notes, fmt.Sprintf("github ON — api/create_repo/upload_file (%s; default repo=%q)", tokState, cfg.GitHubRepo))
}
if cfg.AllowShell {
// run_shell is LINUX-ONLY: it runs an ARBITRARY /bin/sh command line, so an
// executable allowlist can't meaningfully gate it — the strong Linux cage
// (namespaces + Landlock + seccomp) is the boundary. On Windows sandbox.
// Available() is true (Job Object + low-IL), but there is no /bin/sh and no
// allowlist to constrain an arbitrary command line, so run_shell is NOT
// granted; the operator should use --allow-run (the restricted direct-exec
// runner) instead. Fail-closed on a non-Linux or unavailable cage.
ok, why := sandbox.Available()
switch {
case ok && runtime.GOOS == "linux":
pol.WithShell(true)
scratch := filepath.Join(absWt, ".agent-scratch")
tools = append(tools, ShellTools(pol, absWt, scratch)...)
res.ShellGranted = true
res.Notes = append(res.Notes, fmt.Sprintf("shell ON — Linux OS cage (%s); worktree=%s (no network, FS-confined, syscall-limited)", why, absWt))
case runtime.GOOS != "linux":
res.Notes = append(res.Notes, "shell (run_shell) is Linux-only; use --allow-run for a confined runner on Windows.")
default:
res.Notes = append(res.Notes, fmt.Sprintf("shell requested but OS sandbox unavailable (%s) — NOT granted (fail-closed)", why))
}
}
if cfg.AllowRun {
// The restricted runner: grant on BOTH Linux and Windows whenever the OS
// sandbox is available. The tool-layer executable allowlist (runtool.go) is the
// primary control (it also covers Linux, where the cage ignores
// AllowedExecutables); the sandbox is defense-in-depth. Fail-closed if the OS
// sandbox can't be enforced here.
if ok, why := sandbox.Available(); ok {
pol.WithShell(true) // `run` shares the ActShell broker capability
scratch := filepath.Join(absWt, ".agent-scratch")
tools = append(tools, RunTools(pol, absWt, scratch)...)
res.RunGranted = true
res.Notes = append(res.Notes, fmt.Sprintf("run ON — allowlisted direct-exec runner in the OS sandbox (%s); worktree=%s", why, absWt))
} else {
res.Notes = append(res.Notes, fmt.Sprintf("run requested but OS sandbox unavailable (%s) — NOT granted (fail-closed)", why))
}
}
if cfg.AllowBrowse {
hosts := normalizeBrowseHosts(cfg.BrowseHosts)
browseUnattended := cfg.Unattended || cfg.BrowseAgentDoor
switch {
case cfg.Browse == nil:
res.Notes = append(res.Notes, "browse requested but this door has no configured browse lane — NOT granted")
case cfg.AuditPath == "":
res.Notes = append(res.Notes, "browse requested without an audit path — NOT granted (every browse run must leave a trail)")
case browseUnattended && len(hosts) == 0:
res.Notes = append(res.Notes, "browse requested on an unattended run without a host allowlist — NOT granted (fail-closed)")
default:
bt := cfg.BrowseTimeout
if bt <= 0 {
bt = 6 * time.Minute
}
pol.WithBrowse(true)
tools = append(tools, BrowseTools(pol, cfg.Browse, hosts, browseUnattended, bt)...)
res.BrowseGranted = true
scope := "any http(s) host"
if len(hosts) > 0 {
scope = strings.Join(hosts, ", ")
}
res.Notes = append(res.Notes, fmt.Sprintf("browse ON — the operator's own browser, hosts: %s (publish/send-class controls refused; page text goes to the decision endpoint)", scope))
}
}
client := NewLLMClient(cfg.PlannerBase, cfg.Model, "", timeout).WithStreamTokenIDs(cfg.StreamTokenIDs) // local planner, keyless
// The system prompt advertises only what was actually granted — ShellGranted,
// not the raw flag, so a cage-refused shell is never advertised to the model. A
// SystemPromptOverride (P6 flywheel replay of a candidate prompt) replaces it.
sys := SystemPrompt(cfg.AllowWrite, cfg.AllowOverwrite, cfg.AllowFetch, res.ShellGranted, res.RunGranted, cfg.AllowSearch, cfg.AllowGitHub)
if res.BrowseGranted {
sys += "\n- browse: drive the operator's own browser toward a goal (start url + goal with every value to type). " +
"Publish/send/delete/pay-class controls are refused. A done status is a claim: read final.text before relying on it."
}
if cfg.SystemPromptOverride != "" {
sys = cfg.SystemPromptOverride
}
loop := NewLoop(client, tools, maxSteps).WithSystem(sys).WithMaxTokens(maxTokens).WithParkHighRisk(cfg.Unattended)
if askQueue != nil {
// Parked high-risk calls land in the SAME reviewable queue as deferred
// asks — they are the highest-signal deferred asks the system produces.
loop.WithParkRecorder(func(tool, args, risk string) {
_ = askQueue.Record(Action{Kind: ActPark, Path: tool + " " + args}, Ask,
"self-flagged security_risk="+risk+"; parked on unattended run")
})
}
if cfg.MaxSameTool != 0 {
loop = loop.WithMaxSameTool(cfg.MaxSameTool) // 0 (unset) leaves NewLoop's built-in default (8); negative disables
}
if cfg.Memory != nil {
loop = loop.WithMemory(cfg.Memory)
}
if cfg.EvidenceReducer {
if cfg.Offload == nil {
res.Notes = append(res.Notes, "agent_evidence_reducer is on but this build has no cascade seam (Offload), so the evidence reducer is not installed")
} else {
loop = loop.WithEvidenceReducer(cfg.Offload)
res.Notes = append(res.Notes, "evidence reducer: build and test results of 4 KiB or more become receipts whose quotes were found in the output, or stay raw")
}
}
if !cfg.EnvRules.IsZero() {
compiled, cerr := CompileEnvRules(cfg.EnvRules)
if cerr != nil {
return nil, cerr
}
before := make([]string, 0, len(loop.specs))
for _, s := range loop.specs {
before = append(before, s.Name)
}
loop = loop.WithEnvRules(compiled)
note := "agent env rules: " + cfg.EnvRules.Summary()
if denied := compiled.DeniedTools(before); len(denied) > 0 {
note += " (withheld: " + strings.Join(denied, ", ") + ")"
}
res.Notes = append(res.Notes, note)
}
if !cfg.Sampling.IsZero() || !cfg.SamplingFinal.IsZero() {
loop = loop.WithSampling(cfg.Sampling, cfg.SamplingFinal)
note := "agent sampling: planner " + cfg.Sampling.Summary()
if !cfg.SamplingFinal.IsZero() {
note += "; final " + cfg.SamplingFinal.Summary()
}
res.Notes = append(res.Notes, note)
}
if mode, merr := ParseThinkingMode(cfg.Thinking); merr != nil {
return nil, fmt.Errorf("agent.Build: %w", merr)
} else if mode != ThinkingAuto {
loop = loop.WithThinking(mode)
}
if len(cfg.SetupActions) > 0 {
loop = loop.WithSetupActions(cfg.SetupActions)
names := make([]string, 0, len(cfg.SetupActions))
for _, a := range cfg.SetupActions {
names = append(names, a.Tool)
}
res.Notes = append(res.Notes, "agent setup actions: "+strings.Join(names, ", "))
}
res.Loop = loop
res.Tools = tools
return res, nil
}