Repository navigation
Expand file tree
/
Copy pathtools.go
More file actions
704 lines (672 loc) · 43 KB
/
Copy pathtools.go
File metadata and controls
704 lines (672 loc) · 43 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
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
package main
import (
"context"
"encoding/json"
"fmt"
"html"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"unicode/utf8"
)
// The tool belt. Each call runs with a per-request context that collects
// artifacts so the Discord layer can attach whatever the agent produced.
type ToolCtx struct {
cfg *Config
authorID string // Discord user id of the requester — gates the coder allowlist
author string // display name, used to carry forum-post context forward
request string // original turn that caused a forum post
guildID string // guild of the current turn (for Discord actions)
channelID string // channel of the current turn
disc Discord // Discord actuator (nil in headless/eval)
Artifacts []string // file paths saved this turn
usedWeb bool // this turn touched the web (fetch/search)
usedCode bool // this turn ran code (shell/file) — mutually exclusive with web
boundary *phaseBoundary
exhausted bool // this lane used its full tool budget without a final answer
repoReads int // focused GitHub inspection calls; bounded to prevent analysis loops
ghCache *ghRepoCache // turn-scoped GitHub tree/blob cache for remote search
appSpec *AppSpec // structured build specification for the current turn/job
// scaffoldTip records {commit sha → repo} at the moment create_rails_app
// finished shaping, so enqueue_build can refuse to ship a repo whose tip
// is still the untouched scaffold (the worker writes no code).
scaffoldTip map[string]string
// notify posts a mid-turn status message (supplied by Discord). Nil in eval.
notify func(text string)
// setBuildName updates the build-lane label while a coder turn runs (so
// /request can name what is building). Nil outside Discord.
setBuildName func(name string)
// deadline is the real per-turn budget from turnDeadline — interim messages
// read this instead of hardcoding a duration.
deadline time.Duration
// gh, when set, is used instead of newGH (tests inject a stub client).
gh *ghClient
}
// buildStartedMessage is the one-shot interim line posted when a Rails app
// scaffold begins. Minutes come from the turn's real deadline (ceil), phrased
// as an upper bound — not a promise of exactly N.
func buildStartedMessage(name string, deadline time.Duration) string {
mins := int((deadline + time.Minute - 1) / time.Minute)
if mins < 1 {
mins = 1
}
label := strings.TrimSpace(name)
if label == "" {
label = "the app"
}
return fmt.Sprintf("Building %s — up to about %d minutes. I'll post the repository and demo when it's verified.", label, mins)
}
// notifyBuildStarted posts (or logs) the interim build message and refreshes
// the build-lane label for /request status. Safe when callbacks are nil —
// eval and other headless paths proceed silently.
func (tc *ToolCtx) notifyBuildStarted(name string) {
if tc.setBuildName != nil {
tc.setBuildName(name)
}
msg := buildStartedMessage(name, tc.deadline)
if tc.notify == nil {
log.Printf("build started (no channel notifier): %s", msg)
return
}
tc.notify(msg)
}
// phaseBoundary is an attempted web↔code transition. The tool stays blocked,
// but the agent consumes this signal and starts a fresh internal phase instead
// of exposing a REFUSED dead end to the user.
type phaseBoundary struct {
Lane string // web | code
Tool string
}
// clip truncates on a UTF-8 rune boundary so we never split a character.
func clip(s string, n int) string {
if len(s) <= n {
return s
}
for n > 0 && !utf8.RuneStart(s[n]) {
n--
}
return s[:n] + " …[truncated]"
}
func toolDefs(cfg *Config) []ToolDef {
mk := func(name, desc, params string) ToolDef {
var t ToolDef
t.Type = "function"
t.Function.Name = name
t.Function.Description = desc
t.Function.Parameters = json.RawMessage(params)
return t
}
defs := []ToolDef{
mk("web_search",
"Search the web (DuckDuckGo). Use for benchmarks, model releases, docs, news. Returns titles, URLs, snippets.",
`{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}`),
mk("x_pulse",
"What X (Twitter) is saying about something: trends, sentiment, takes on a topic/ticker/event. Grok live-searches real X posts and returns a short digest. ALWAYS use this (never web_search) for questions about X opinion/trends/vibes, and relay the digest essentially as-is — short reply, no links, no added jargon or background.",
`{"type":"object","properties":{"query":{"type":"string","description":"topic, ticker, handle, or event"},"days":{"type":"integer","description":"lookback window in days (default 7, max 30)"}},"required":["query"]}`),
mk("fetch_url",
"Fetch a web page and return its readable text (truncated). Use after web_search to read a source. For external datasets/catalogs the app will load: fetch ONE SMALL SAMPLE only (schema or a single page/record) to learn field names and shape, then write an in-app importer — never page bulk rows into your context.",
`{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]}`),
mk("save_artifact",
"Save a file the user gets as a Discord attachment. Use for HTML mockups (self-contained, inline CSS/JS), code files, markdown docs, diagrams. name must include an extension.",
`{"type":"object","properties":{"name":{"type":"string"},"content":{"type":"string"}},"required":["name","content"]}`),
mk("remember",
"Append a durable note to long-term memory (survives reboots, shared across all channels). Use for server preferences, ongoing projects, decisions.",
`{"type":"object","properties":{"note":{"type":"string"}},"required":["note"]}`),
mk("tcg",
"Look up any TCG card, set, or market price from the open rarebox-data dataset — Pokémon (EN/JP), Magic, Yu-Gi-Oh!, Lorcana, One Piece (EN/JP), Riftbound. To price/find a CARD, just pass its name as `query` (no `set`): it searches cards by name across the newest sets and returns each match's set id, number, rarity, USD price, and image URL — you do NOT need to know the set. Pass a set id as `set` to list that set's cards. This is the source of truth for a single card's price, JP included — use it, not web_search. Reserve web_search for SEALED products or live market chatter.",
`{"type":"object","properties":{"game":{"type":"string","description":"pokemon|pokemon-ja|mtg|yugioh|lorcana|one-piece|one-piece-ja|riftbound"},"set":{"type":"string","description":"set id (e.g. me2); omit and pass query to search cards by name across sets"},"query":{"type":"string","description":"card name (searches across sets) or set name"}},"required":["game"]}`),
mk("attach_image",
"Fetch an image by URL and attach it to your Discord reply (e.g. a card image from a tcg lookup, or any picture the user asks to see). Images only, up to 8MB.",
`{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]}`),
mk("price_chart",
"Build a historical price chart IMAGE (a PNG that displays inline in Discord) and attach it to your reply. kind=card|stock|crypto|index. "+
"For a CARD: tcg-lookup it first, then pass game, set, number, and query=<card name> (uses rarebox price history, daily ~90d). "+
"For CRYPTO: query=<name/symbol e.g. bitcoin> (CoinGecko). For a STOCK: symbol=<ticker e.g. AAPL> (Yahoo). "+
"For an INDEX (a whole game's market, Card Ladder-style): kind=index + game — charts an equal-weight base-100 index of that game's tracked cards (newest sets), e.g. 'how's the pokemon market doing?'. "+
"Optional days (default 30, cards/index 90). The output is a STATIC PNG — it has no hover or interactivity, so never describe it as interactive. "+
"Card history covers EVERY set that still trades, VINTAGE INCLUDED — never refuse an old card as unchartable; call this and let it answer. "+
"SANITY-CHECK every card chart: if the chart's latest price differs wildly from the price you quoted, you charted the WRONG PRINTING — redo with the correct number instead of shipping it with a caveat. "+
"This is neutral price data — not advice.",
`{"type":"object","properties":{"kind":{"type":"string","description":"card|stock|crypto|index"},"game":{"type":"string"},"set":{"type":"string"},"number":{"type":"string"},"symbol":{"type":"string","description":"stock ticker"},"query":{"type":"string","description":"crypto name/symbol, or card display name"},"days":{"type":"integer"}},"required":["kind"]}`),
mk("bench_chart",
"Render an LLM benchmark comparison as a grouped-bar chart IMAGE (a PNG that displays inline in Discord) attached to your reply. "+
"Use AFTER researching real, dated scores with web_search/fetch_url — you pass the numbers in; NEVER guess a score. "+
"scores align 1:1 with benchmarks; use null where a model doesn't report that benchmark. "+
"Keep model order stable across re-renders (append newly requested models at the end) so each model keeps its color.",
`{"type":"object","properties":{"title":{"type":"string","description":"chart title, e.g. 'DeepSeek-V4 vs Llama 3.1 405B'"},"source":{"type":"string","description":"short provenance note with a date, e.g. 'vendor model cards · Aug 2026'"},"benchmarks":{"type":"array","items":{"type":"string"},"description":"benchmark names, e.g. MMLU-Pro, GPQA Diamond, SWE-bench Verified (max 10)"},"models":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"scores":{"type":"array","items":{"type":["number","null"]}}},"required":["name","scores"]},"description":"one entry per model (max 8), scores aligned to benchmarks"}},"required":["benchmarks","models"]}`),
mk("model_releases",
"Check Hugging Face for recent model releases from the labs worth tracking (DeepSeek, Qwen, Meta Llama, Mistral, Google, OpenAI oss, Microsoft, etc.). "+
"Optional query filters to one lab/name (e.g. 'qwen', 'deepseek'). Returns model id, lab, date, and downloads, newest first, and flags which are NEW since the last check. Use for 'any new models out?' / 'what did deepseek just drop?'.",
`{"type":"object","properties":{"query":{"type":"string","description":"optional: a lab or name to filter (qwen|deepseek|llama|...)"}}}`),
mk("discord_forum",
"Post in a Discord FORUM channel on request. action=post creates a new forum post/thread (needs channel=<forum name or id>, title, body); action=reply adds a message to an existing post/thread (needs thread=<thread id or its title/name>, body). Use when asked to 'post an intro in the introductions forum' or 'reply to that forum post'. Write the content in Vela's voice.",
`{"type":"object","properties":{"action":{"type":"string","description":"post|reply"},"channel":{"type":"string","description":"forum channel name or id (for post)"},"title":{"type":"string","description":"post title (for post)"},"thread":{"type":"string","description":"thread id or title (for reply)"},"body":{"type":"string","description":"the message content"}},"required":["action","body"]}`),
}
if cfg.XAIEnabled() { // Grok image/video generation (needs XAI_API_KEY)
defs = append(defs,
mk("generate_image",
"Generate image(s) with Grok (xAI) and attach them to your reply. Use when asked to 'make/draw/generate a picture/image of ...'. prompt = what to create (be vivid and specific); n = how many (1-4, default 1); optional image_url to use a reference image to edit/riff on. Costs money per image — generate what's asked, don't spam extras.",
`{"type":"object","properties":{"prompt":{"type":"string"},"n":{"type":"integer","description":"1-4"},"image_url":{"type":"string","description":"optional reference image to edit/riff on"}},"required":["prompt"]}`),
mk("generate_video",
"Generate a short video with Grok (xAI) and attach it. Use when asked to 'make/generate a video/clip of ...' or to animate an image. prompt = what happens in the clip; optional image_url to animate a still; duration = seconds as a string (default '5', max '15'). Async — it renders over several seconds. Costs money per second — keep clips short unless asked.",
`{"type":"object","properties":{"prompt":{"type":"string"},"image_url":{"type":"string","description":"optional still image to animate"},"duration":{"type":"string","description":"seconds, e.g. '5' (max '15')"}},"required":["prompt"]}`))
}
if len(cfg.Mods) > 0 { // moderation only when a mod allowlist is configured
defs = append(defs, mk("moderate",
"Discord moderation — ONLY act on an explicit request from an authorized moderator. action=timeout|kick|ban|delete|slowmode. "+
"user=<@mention/id/username> for timeout/kick/ban; duration like '10m'/'1h'/'1d' for timeout; reason is logged; "+
"delete removes a specific message (message=<id> in the current channel); slowmode sets per-user seconds (seconds=0 clears) on the current channel. Always state what you did and why.",
`{"type":"object","properties":{"action":{"type":"string","description":"timeout|kick|ban|delete|slowmode"},"user":{"type":"string"},"duration":{"type":"string","description":"timeout length e.g. 10m, 1h, 1d"},"reason":{"type":"string"},"message":{"type":"string","description":"message id to delete"},"seconds":{"type":"integer","description":"slowmode: per-user seconds (0 clears)"},"days":{"type":"integer","description":"ban: how many days of the user's messages to delete"}},"required":["action"]}`))
}
if cfg.GithubEnabled() || cfg.FoundationRoot != "" { // structured build spec; module list comes from the foundation checkout
modList := declaredModulesSummary(cfg.FoundationRoot)
defs = append(defs, mk("app_spec",
"Produce or revise the structured APPLICATION SPECIFICATION before writing product code. "+
"action=set replaces the whole spec (required fields: name, purpose, entities[], workflows[]); "+
"action=amend overlays fields onto the current spec and re-validates (rejected amendments leave the prior spec unchanged); "+
"action=show returns the current spec. "+
"entities: core domain objects with key relationships. workflows: primary things a user does. "+
"modules: foundation modules to KEEP (omit the rest at generation time). Declared modules: "+modList+". "+
"Unknown module names are rejected. integrations: external systems + demo_adapter bool. seed_demo: what demo data should show. "+
"Optional repo commits the validated JSON to "+appSpecRepoPath+" in the generated app (customer-owned intent record). "+
"BUILD FLOW: app_spec → create_rails_app (auto-shapes identity/README/modules) → focused edits → verify_repo → deploy_repo.",
`{"type":"object","properties":{"action":{"type":"string","description":"set|amend|show"},"repo":{"type":"string","description":"optional generated app repo to commit docs/APP_SPEC.json into"},"name":{"type":"string"},"purpose":{"type":"string"},"actors":{"type":"array","items":{"type":"string"}},"entities":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"relationships":{"type":"array","items":{"type":"string"}}},"required":["name"]}},"workflows":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"}},"required":["name"]}},"modules":{"type":"array","items":{"type":"string"},"description":"foundation module names to include"},"integrations":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"demo_adapter":{"type":"boolean"}},"required":["name"]}},"seed_demo":{"type":"string"}},"required":["action"]}`))
}
if cfg.GithubEnabled() { // API only — no shell; gated by VELA_REPO_USERS (empty = everyone)
defs = append(defs, mk("github",
"Create and populate GitHub repos and open pull requests via the API, as Vela's own account. This is API-ONLY — nothing runs on the box. Actions: "+
"create_rails_app {name, description?} — creates a Ruby on Rails app from Vela's configured production framework. The new repo starts PRIVATE. Prefer app_spec first, but create always auto-shapes even without one: stamps application_name/domain/support_email into config/foundation.yml (never leave Application/example.com), rewrites the app README, and omits foundation modules not listed in app_spec.modules when a spec is present. The returned result already lists what was shaped — do not redo shape; "+
"shape {repo} — optional re-shape of an existing generated app (identity/README/module omission). create_rails_app already shapes; only use this to re-apply after a later app_spec change; "+
"publish_app {repo} — makes a finished app public and forkable. Only after it carries the requested product's own identity and has passed verify_repo. Refused when the operator has publication turned off, which is normal; say so plainly rather than retrying; "+
"create_repo {name, description?} — legacy/non-app repository action; refused when the Rails framework is configured; "+
"search_code {repo, pattern, path?, glob?, ref?} — regex search over the remote repo (works on brand-new repos). Returns path:line: text like workspace search_code. SEARCH BEFORE read_files; narrow with path/glob; "+
"list_tree {repo, ref?, path?} — list paths only when you need structure, not contents; "+
"describe_schema {repo, tables?:[names], ref?} — compact table/column/index/FK view parsed from db/schema.rb. Omit tables to list names only. USE THIS instead of read_files on db/schema.rb — reading the whole schema is wasteful and truncates; "+
"read_files {repo, paths:[up to 3], ref?, start_line?, end_line?} — read files you will edit (not symbol hunt; use search_code). Optional 1-based line range; omit for whole file. Large files return a window plus the exact next-range call — never fetch_url/shell for repo files. Do not use for db/schema.rb (use describe_schema); "+
"patch_file {repo, path, ops, message?, branch?} — targeted edits (same ops as apply_patch: replace|insert_after|insert_before|delete; each find must match exactly once or nothing is committed). Prefer for small edits; "+
"NEVER write db/schema.rb, db/structure.sql, or a lockfile — they are generated; add a migration under db/migrate/ or change the Gemfile instead; "+
"put_file {repo, path, content, message?, branch?} — REPLACES THE ENTIRE FILE and commits (new files or full rewrites only; repo is 'name' or 'owner/name'); "+
"delete_file {repo, path, message?, branch?} — deletes one file from a Vela-owned repository without shell/curl; "+
"open_pr {repo:'owner/name', title, head, base?, body?} — head is 'branch' (same repo) or 'forkowner:branch' (from a fork); "+
"fork {repo:'owner/name'}; "+
"enable_pages {repo} — legacy static publishing, never completion for an app request. "+
"TO DEPLOY AN APP: app_spec → create_rails_app (auto-shapes identity/README/modules) → focused edits → verify_repo → deploy_repo. Never substitute standalone HTML, Node, Python, Go, or PHP. "+
"To PR into someone else's repo: fork it, patch_file/put_file onto a new branch in the fork, then open_pr on the upstream with head 'velaoc:branch'.",
`{"type":"object","properties":{"action":{"type":"string","description":"create_repo|create_rails_app|shape|publish_app|search_code|list_tree|read_files|describe_schema|patch_file|put_file|delete_file|open_pr|fork|enable_pages"},"name":{"type":"string"},"description":{"type":"string"},"repo":{"type":"string"},"path":{"type":"string"},"paths":{"type":"array","items":{"type":"string"},"maxItems":3},"tables":{"type":"array","items":{"type":"string"},"description":"describe_schema: table names; omit to list all names"},"content":{"type":"string"},"message":{"type":"string"},"branch":{"type":"string"},"ref":{"type":"string"},"pattern":{"type":"string"},"glob":{"type":"string"},"start_line":{"type":"integer","description":"read_files: optional 1-based start line"},"end_line":{"type":"integer","description":"read_files: optional 1-based end line (inclusive)"},"ops":{"type":"array","items":{"type":"object","properties":{"op":{"type":"string"},"find":{"type":"string"},"replace":{"type":"string"},"text":{"type":"string"}},"required":["op","find"]}},"title":{"type":"string"},"head":{"type":"string"},"base":{"type":"string"},"body":{"type":"string"}},"required":["action"]}`))
}
if cfg.SandboxURL != "" && cfg.SandboxToken != "" && cfg.SandboxSecret != "" { // Holodex demo hosting
defs = append(defs, mk("deploy_demo",
"Legacy lightweight artifact deploy. When the Rails foundation (VELA_RAILS_TEMPLATE) is configured this refuses application deployment: every Vela app must use create_rails_app → verify_repo → deploy_repo. Never use this to bypass the Rails framework. The whole deck WIPES DAILY at 3AM Mexico City.",
`{"type":"object","properties":{"name":{"type":"string","description":"short app name — becomes the subdomain slug"},"port":{"type":"integer","description":"container app's listen port (optional; else EXPOSE or 8080)"},"files":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string","description":"relative path, e.g. index.html, Dockerfile, src/app.js"},"content":{"type":"string"}},"required":["path","content"]}}},"required":["name","files"]}`))
if cfg.CodeEnabled() && cfg.GithubEnabled() {
defs = append(defs,
mk("verify_repo",
"Build and test a whole GitHub repository on Holodex's disposable build server instead of the tiny Nano. Coder-only. Downloads with Vela's token (private repos work), streams source without exposing the token, builds the Dockerfile's `test` target and deployable final image, then returns bounded logs plus a signed one-hour receipt tied to the exact commit/archive. Use after pushing code and BEFORE deploy_repo. Args: repo='name' or 'owner/name'; ref optional branch/SHA; target defaults to test; dockerfile defaults to Dockerfile.",
`{"type":"object","properties":{"repo":{"type":"string"},"ref":{"type":"string","description":"branch/tag/SHA; defaults to repository default branch"},"name":{"type":"string","description":"build display name; defaults to repo"},"target":{"type":"string","description":"Docker build target; defaults to test"},"dockerfile":{"type":"string","description":"Dockerfile path; defaults to Dockerfile"}},"required":["repo"]}`),
mk("deploy_repo",
"Deploy a verified Ruby on Rails GitHub repository to Holodex, coder-only. REQUIRED for every Vela application. REQUIRES the exact commit SHA and signed receipt returned by verify_repo; Holodex rejects changed, expired, or untested source. Runtime remains locked down with no internet egress and the whole deck wipes daily.",
`{"type":"object","properties":{"repo":{"type":"string"},"name":{"type":"string","description":"app/display name"},"ref":{"type":"string","description":"exact commit SHA returned by verify_repo"},"receipt":{"type":"string","description":"signed receipt returned by verify_repo"},"port":{"type":"integer","description":"listen port; otherwise root Dockerfile EXPOSE or 8080"}},"required":["repo","name","ref","receipt"]}`))
if cfg.WorkerEnabled() {
defs = append(defs, mk("enqueue_build",
"REQUIRED final step of every build: hand your finished, pushed implementation to the deploy worker and return immediately. The worker pins the current tip of the repo, verifies that exact commit on Holodex, and deploys it automatically — progress and the live URL are posted in this thread without you. Use AFTER app_spec + create_rails_app + your own focused edits are committed and pushed; the worker writes no code, so enqueue only when the app is done. Args: repo (owner/name), name (app/display name), instructions (context recorded with the job), port (optional). Do NOT then call verify_repo/deploy_repo yourself.",
`{"type":"object","properties":{"repo":{"type":"string"},"name":{"type":"string"},"instructions":{"type":"string","description":"product detail and constraints for the worker"},"port":{"type":"integer"}},"required":["repo","name"]}`))
}
}
}
if cfg.CodeEnabled() { // gated to the coder allowlist (VELA_CODERS)
defs = append(defs,
mk("shell",
"Run a shell command in your code workspace (persists across turns). Use for git, libraries, builds, tests, and scaffolding. 180s timeout. You run on a memory-limited single-core RISC-V board; keep local work light and use verify_repo for heavy repository builds. NOTE: code and web fetches run in isolated phases (injection guard); Vela checkpoints and changes phases automatically, so never ask the user to say continue just to switch.",
`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}`),
mk("write_file",
"Write a whole file in your workspace (creates dirs). Prefer apply_patch for small edits. Result includes +added/-removed line counts.",
`{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}`),
mk("apply_patch",
"Apply targeted edits to an existing workspace file without rewriting it. ops is an ordered list; each op's find text must match EXACTLY once in the file at that step or the whole call fails and nothing is written. "+
"op=replace needs find+replace; op=insert_after|insert_before needs find+text; op=delete needs find. Prefer this over write_file for surgical changes.",
`{"type":"object","properties":{"path":{"type":"string"},"ops":{"type":"array","items":{"type":"object","properties":{"op":{"type":"string","description":"replace|insert_after|insert_before|delete"},"find":{"type":"string"},"replace":{"type":"string"},"text":{"type":"string"}},"required":["op","find"]}}},"required":["path","ops"]}`),
mk("search_code",
"Search workspace file contents by regex pattern. Optional path confines to a subdirectory/file; optional glob filters by filepath (e.g. *.rb). Skips .git, node_modules, vendor, tmp, log, and binaries. Returns path:line: text, capped with an explicit truncation note.",
`{"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string","description":"optional subdirectory or file"},"glob":{"type":"string","description":"optional filepath.Match filter e.g. *.rb"}},"required":["pattern"]}`),
mk("read_file",
"Read a workspace file. Optional start_line/end_line (1-based, inclusive) for large files; truncated reads name total lines and the exact next-range call. Prefer this over shell/cat or fetch_url.",
`{"type":"object","properties":{"path":{"type":"string"},"start_line":{"type":"integer","description":"optional 1-based start line"},"end_line":{"type":"integer","description":"optional 1-based end line (inclusive)"}},"required":["path"]}`))
names := cfg.Secrets.Names()
desc := "Wipe deploy secrets from memory and disk when a deploy/task is done (do this as soon as you no longer need them). "
if len(names) > 0 {
desc += "Currently held (values hidden, injected into shell as these env vars): " + strings.Join(names, ", ") + "."
} else {
desc += "None are set right now — they're added on the box, not in chat."
}
defs = append(defs, mk("clear_secrets", desc, `{"type":"object","properties":{}}`))
}
return defs
}
// readOnlyToolNames are the pure lookups a self-review pass may always use:
// they inform a better text answer with NO side effect.
var readOnlyToolNames = map[string]bool{
"web_search": true, "fetch_url": true, "tcg": true, "model_releases": true, "x_pulse": true,
}
// freeArtifactToolNames render/attach LOCALLY with no cost and no external side
// effect. They're offered in critique passes ONLY until one artifact exists this
// turn — so a chart the draft ran out of budget to make can still be produced,
// exactly once, without duplication. (Paid/side-effecting tools — Grok image/
// video gen, moderation, forum posts, github, shell — are NEVER in critique.)
var freeArtifactToolNames = map[string]bool{
"bench_chart": true, "price_chart": true, "save_artifact": true, "attach_image": true,
}
// critiqueTools is the belt for a self-review pass. haveArtifact drops the local
// artifact builders once something's been produced, preventing duplicates.
func critiqueTools(cfg *Config, haveArtifact bool) []ToolDef {
var out []ToolDef
for _, d := range toolDefs(cfg) {
n := d.Function.Name
if readOnlyToolNames[n] || (!haveArtifact && freeArtifactToolNames[n]) {
out = append(out, d)
}
}
return out
}
type toolArgs struct {
Query, URL, Name, Content, Note, Command, Path, Game, Set string
Action, Description, Repo, Message, Branch, Title, Head, Base, Body string
Ref, Target, Dockerfile, Receipt string
Kind, Number, Symbol, Source string
User, Reason, Channel, Thread, Duration string // moderation + forum
Prompt string // xAI image/video gen
Pattern, Glob string // search_code
// snake_case keys need explicit tags — Go's JSON matching ignores case but
// NOT underscores, so without this "image_url" silently unmarshals to "".
ImageURL string `json:"image_url"`
Days int
Seconds int // slowmode seconds
N int // image count
Benchmarks []string
Models []benchModel
Files []demoFile // deploy_demo: the app's files
Paths []string // github read_files: focused repository files
Tables []string `json:"tables"` // github describe_schema: table names
Ops []patchOp // apply_patch
Port int // deploy_demo: container app's listen port (optional)
StartLine int `json:"start_line"` // read_file / read_files: 1-based; 0 = omit
EndLine int `json:"end_line"` // inclusive; 0 = omit (through EOF / budget)
Instructions string `json:"instructions"` // enqueue_build: request context for the worker
}
type demoFile struct {
Path string `json:"path"`
Content string `json:"content"`
}
// unwrapArgs undoes DeepSeek's occasional double-wrapping of tool arguments,
// where the real args arrive nested under a lone "arguments" key —
// {"arguments":"{\"path\":...}"} or {"arguments":{...}} — instead of at the top
// level. Left unwrapped, every field reads empty and file/artifact tools fail
// silently (seen killing a /dive's save_artifact). Only unwraps the exact lone-
// "arguments" shape, so real args that happen to contain an "arguments" field
// are untouched.
func unwrapArgs(s string) string {
var probe map[string]json.RawMessage
if json.Unmarshal([]byte(s), &probe) != nil || len(probe) != 1 {
return s
}
inner, ok := probe["arguments"]
if !ok {
return s
}
var asStr string
if json.Unmarshal(inner, &asStr) == nil {
return asStr // was a JSON string: {"arguments":"{...}"}
}
return string(inner) // was a nested object: {"arguments":{...}}
}
func (tc *ToolCtx) Run(name, args string) string {
args = unwrapArgs(args)
var a toolArgs
if err := json.Unmarshal([]byte(args), &a); err != nil {
return "tool error: bad arguments: " + err.Error()
}
// Injection guard: untrusted web content and box-writing actions (shell,
// files, or GitHub writes) must never coexist in one turn, or a poisoned
// page fetched this turn could drive the model into running commands or
// pushing code. The two sides are mutually exclusive per turn (they persist
// across the whole dive too).
// attach_image counts as web: its bytes never reach the model, but it IS an
// arbitrary outbound GET — after a code turn (which can read env/tokens) it
// would otherwise be an exfiltration channel via the URL.
web := name == "web_search" || name == "fetch_url" || name == "tcg" || name == "price_chart" || name == "attach_image" || name == "model_releases" || name == "generate_image" || name == "generate_video" || name == "x_pulse"
// app_spec is structural (may commit via GitHub) — treat as code lane when it touches the repo.
code := name == "shell" || name == "write_file" || name == "apply_patch" || name == "search_code" || name == "read_file" || name == "github" || name == "deploy_demo" || name == "verify_repo" || name == "deploy_repo" || name == "enqueue_build" || name == "app_spec"
if web && tc.usedCode {
tc.boundary = &phaseBoundary{Lane: "web", Tool: name}
return "PHASE_BOUNDARY: web fetch deferred to a fresh isolated read-only phase."
}
if code && tc.usedWeb {
tc.boundary = &phaseBoundary{Lane: "code", Tool: name}
return "PHASE_BOUNDARY: code action deferred to a fresh isolated execution phase."
}
switch name {
case "web_search":
tc.usedWeb = true
if tc.cfg.BraveKey != "" {
return braveSearch(tc.cfg.BraveKey, a.Query)
}
return webSearch(a.Query)
case "fetch_url":
tc.usedWeb = true
return fetchURL(a.URL)
case "x_pulse":
tc.usedWeb = true
return tc.xPulse(a)
case "tcg":
tc.usedWeb = true
return tcgLookup(a.Game, a.Set, a.Query)
case "attach_image":
tc.usedWeb = true
return tc.attachImage(a.URL)
case "price_chart":
tc.usedWeb = true
return tc.priceChart(a)
case "bench_chart":
// renders only from the args the model passes — no fetch, so it's
// neither a web nor a code action for the injection guard
return tc.benchChart(a)
case "save_artifact":
return tc.saveArtifact(a.Name, a.Content)
case "remember":
return appendMemory(tc.cfg, a.Note)
case "clear_secrets":
return tc.clearSecrets()
case "model_releases":
tc.usedWeb = true
return tc.modelReleases(a.Query)
case "moderate":
return tc.moderate(a)
case "discord_forum":
return tc.discordForum(a)
case "generate_image":
tc.usedWeb = true
return tc.generateImage(a)
case "generate_video":
tc.usedWeb = true
return tc.generateVideo(a)
// The code handlers set usedCode themselves AFTER their allowlist gates
// pass — a REFUSED call ran nothing, so it must not poison the rest of the
// turn (blocking every later web tool with "this turn already ran code").
case "app_spec":
return tc.runAppSpec(args)
case "github":
return tc.runGithub(a)
case "deploy_demo":
return tc.deployDemo(a)
case "verify_repo":
return tc.verifyRepo(a)
case "deploy_repo":
return tc.deployRepo(a)
case "enqueue_build":
return tc.enqueueBuild(a)
case "shell":
return tc.runShell(a.Command)
case "write_file":
return tc.writeWorkspaceFile(a.Path, a.Content)
case "apply_patch":
return tc.applyPatch(a.Path, a.Ops)
case "search_code":
return tc.searchCode(a.Pattern, a.Path, a.Glob)
case "read_file":
return tc.readWorkspaceFile(a.Path, a.StartLine, a.EndLine)
}
return "tool error: unknown tool " + name
}
// blockedIP rejects anything that could reach the local box or its LAN —
// loopback, RFC1918/4193 private, link-local, unspecified, and CGNAT.
func blockedIP(ip net.IP) bool {
// stdlib checks all call To4() internally, so IPv4-mapped IPv6
// (::ffff:192.168.1.1) is unwrapped and caught here too.
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() {
return true
}
// v4 (and mapped v6, which To4 unwraps) ranges the stdlib doesn't cover.
if v4 := ip.To4(); v4 != nil {
switch {
case v4[0] == 0: // 0.0.0.0/8 — Linux routes the whole block to localhost
return true
case v4[0] == 255 && v4[1] == 255 && v4[2] == 255 && v4[3] == 255: // limited broadcast
return true
case v4[0] == 100 && v4[1]&0xc0 == 64: // 100.64.0.0/10 CGNAT
return true
}
}
return false
}
// ssrfClient resolves and validates every dial (so redirects to an internal
// address are caught too) and dials the validated IP directly — no TOCTOU
// gap, and TLS SNI still uses the request host, not the IP.
var ssrfClient = &http.Client{
Timeout: 20 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
return nil
},
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return nil, err
}
var target net.IP
for _, ip := range ips {
if blockedIP(ip) {
return nil, fmt.Errorf("blocked address %s (private/loopback not allowed)", ip)
}
if target == nil {
target = ip
}
}
if target == nil {
return nil, fmt.Errorf("no address for %s", host)
}
d := net.Dialer{Timeout: 10 * time.Second}
return d.DialContext(ctx, network, net.JoinHostPort(target.String(), port))
},
},
}
func getPage(u string) (string, error) {
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; vela/1.0)")
resp, err := ssrfClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", fmt.Errorf("http %d", resp.StatusCode)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
return string(b), err
}
var (
ddgResult = regexp.MustCompile(`(?s)<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)</a>`)
ddgSnippet = regexp.MustCompile(`(?s)<a[^>]*class="result__snippet"[^>]*>(.*?)</a>`)
tagRe = regexp.MustCompile(`(?s)<(script|style|noscript)[^>]*>.*?</(script|style|noscript)>`)
anyTag = regexp.MustCompile(`<[^>]*>`)
wsRe = regexp.MustCompile(`[ \t]*\n[ \t\n]*`)
)
func stripTags(s string) string {
s = tagRe.ReplaceAllString(s, " ")
s = anyTag.ReplaceAllString(s, " ")
s = html.UnescapeString(s)
s = strings.Join(strings.Fields(s), " ")
return s
}
// braveSearch uses the Brave Search API (real JSON, not HTML scraping) and
// TRANSPARENTLY FALLS BACK to DuckDuckGo whenever Brave can't deliver — quota
// exhausted (429), a bad/expired key (401/403), an outage (5xx), a network
// error, or an empty/unparseable response. So hitting the Brave cap never
// stops web search; it just quietly degrades to DDG and Vela keeps working.
func braveSearch(key, query string) string {
if out, ok := braveTry(key, query); ok {
return out
}
log.Printf("brave search unavailable (quota/key/outage) — falling back to DuckDuckGo")
return webSearch(query)
}
func braveTry(key, query string) (string, bool) {
req, _ := http.NewRequest("GET", "https://api.search.brave.com/res/v1/web/search?count=8&q="+url.QueryEscape(query), nil)
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Subscription-Token", key)
resp, err := ssrfClient.Do(req)
if err != nil {
return "", false // network error → DDG
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", false // 429 quota / 401-403 key / 5xx outage → DDG
}
var d struct {
Web struct {
Results []struct{ Title, URL, Description string }
} `json:"web"`
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err := json.Unmarshal(body, &d); err != nil || len(d.Web.Results) == 0 {
return "", false // unparseable or empty → give DDG a shot
}
var b strings.Builder
for i, r := range d.Web.Results {
fmt.Fprintf(&b, "%d. %s\n %s\n %s\n", i+1, r.Title, r.URL, stripTags(r.Description))
}
return b.String(), true
}
func webSearch(query string) string {
page, err := getPage("https://html.duckduckgo.com/html/?q=" + url.QueryEscape(query))
if err != nil {
return "search error: " + err.Error()
}
// Find each result link with its position, then take the snippet that
// falls between this link and the next — so a result with no snippet
// can't shift every later snippet onto the wrong result.
locs := ddgResult.FindAllStringSubmatchIndex(page, 8)
if len(locs) == 0 {
return "no results"
}
var b strings.Builder
for i, loc := range locs {
href := page[loc[2]:loc[3]]
title := stripTags(page[loc[4]:loc[5]])
u := href
if p, err := url.Parse(href); err == nil {
if real := p.Query().Get("uddg"); real != "" {
u = real
}
}
segEnd := len(page)
if i+1 < len(locs) {
segEnd = locs[i+1][0]
}
snippet := ""
if sm := ddgSnippet.FindStringSubmatch(page[loc[1]:segEnd]); sm != nil {
snippet = stripTags(sm[1])
}
fmt.Fprintf(&b, "%d. %s\n %s\n %s\n", i+1, title, u, snippet)
}
return b.String()
}
func fetchURL(u string) string {
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
return "fetch error: absolute http(s) URL required"
}
page, err := getPage(u)
if err != nil {
return "fetch error: " + err.Error()
}
return clip(stripTags(wsRe.ReplaceAllString(page, "\n")), 6000)
}
var unsafeName = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
const maxArtifactBytes = 1 << 20 // 1 MB — plenty for HTML/code/docs; bounds SD + runaway generations
func (tc *ToolCtx) saveArtifact(name, content string) string {
name = unsafeName.ReplaceAllString(filepath.Base(name), "-")
if name == "" || name == "." {
return "artifact error: bad name"
}
if len(content) > maxArtifactBytes {
return fmt.Sprintf("artifact error: too large (%d bytes, max %d) — trim it or split it", len(content), maxArtifactBytes)
}
path := filepath.Join(tc.cfg.DataDir, "artifacts",
fmt.Sprintf("%d-%s", time.Now().UnixNano(), name))
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return "artifact error: " + err.Error()
}
tc.Artifacts = append(tc.Artifacts, path)
return fmt.Sprintf("saved %s (%d bytes) — it will be attached to your reply", name, len(content))
}
const maxImageBytes = 8 << 20 // Discord's baseline attachment limit
var imageExt = map[string]string{
"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp",
"image/gif": ".gif", "image/svg+xml": ".svg",
}
// attachImage fetches an image URL (SSRF-guarded) and attaches it to the reply.
// Only image content-types, capped at Discord's limit. Binary bytes go to
// Discord, never into the model's context — so this isn't an injection vector.
func (tc *ToolCtx) attachImage(u string) string {
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
return "image error: absolute http(s) URL required"
}
resp, err := ssrfClient.Get(u)
if err != nil {
return "image error: " + err.Error()
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Sprintf("image error: http %d", resp.StatusCode)
}
ct := strings.TrimSpace(strings.SplitN(resp.Header.Get("Content-Type"), ";", 2)[0])
// name from the URL PATH only — a query string ("card.png?ex=…") would
// otherwise end up inside the filename and break inline display in Discord
name := u
if p, err := url.Parse(u); err == nil && p.Path != "" {
name = p.Path
}
return tc.saveImage(filepath.Base(name), ct, resp.Body)
}
// saveImage validates the content-type, caps the size, and attaches — split
// out so it's testable without a live SSRF-guarded fetch.
func (tc *ToolCtx) saveImage(name, contentType string, body io.Reader) string {
ext, ok := imageExt[contentType]
if !ok {
// %q + cap: the header is attacker-controlled text headed for model context
return fmt.Sprintf("image error: not an image (content-type %.60q)", contentType)
}
data, err := io.ReadAll(io.LimitReader(body, maxImageBytes+1))
if err != nil {
return "image error: " + err.Error()
}
if len(data) > maxImageBytes {
return "image error: too large (>8MB)"
}
base := unsafeName.ReplaceAllString(name, "-")
if !strings.Contains(base, ".") {
base += ext
}
path := filepath.Join(tc.cfg.DataDir, "artifacts", fmt.Sprintf("%d-%s", time.Now().UnixNano(), base))
if err := os.WriteFile(path, data, 0o644); err != nil {
return "image error: " + err.Error()
}
tc.Artifacts = append(tc.Artifacts, path)
return fmt.Sprintf("fetched %s (%d KB) — it will be attached to your reply", base, len(data)/1024)
}