Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0acaa8c9a5 | |||
| a35c176b42 | |||
| 1cf46c9954 | |||
| 56baac758d | |||
| 5779035722 | |||
| 1a2a2364ec | |||
| c08ce47fa6 | |||
| 784d5d7ce4 | |||
| 4e179259de | |||
| 82a816ae29 | |||
| be4bbbcad5 | |||
| 390e6cf905 | |||
| 1a1d5e417b | |||
| f3bd43b726 | |||
| 306d575c31 | |||
| 4ba83ab905 | |||
| a103cc5e9f | |||
| 4d28cd6e2c | |||
| dcaefff756 | |||
| 97154395e6 | |||
| 4aa06f652e | |||
| 43b2471737 | |||
| 0c80679719 | |||
| 9d41987b0e | |||
| e37cf415de | |||
| a87e7d2c72 | |||
| ea9475da54 | |||
| dc2d4ec425 | |||
| c8559676ed | |||
| d82cef46b4 | |||
| 2260480c81 | |||
| 9116abcae2 | |||
| 4d2f85d139 | |||
| d0bd3ec3d9 | |||
| 78e6858751 | |||
| 1e201550b3 | |||
| df95425bb5 | |||
| 16ddd90914 |
@@ -1,11 +1,8 @@
|
||||
# Gadfly — agentic adversarial PR reviewer (https://gitea.stevedudenhoeffer.com/steve/gadfly).
|
||||
#
|
||||
# Runs the published Gadfly image (pinned to an immutable :sha- tag — act_runner
|
||||
# caches :latest, and this build is what carries foreman provider-type support)
|
||||
# as a specialist swarm and posts
|
||||
# ONE consolidated review comment as gitea-actions. Advisory only — never blocks a
|
||||
# merge. This reviews executus PRs with 3 ollama-cloud models (3-lens suite). Gadfly
|
||||
# is a simple system — findings are advisory; always double-check before acting.
|
||||
# Gadfly adversarial review — subscribes to steve/gadfly's reusable workflow and
|
||||
# INHERITS its default swarm. This stub holds only the triggers, the actor gate,
|
||||
# secret forwarding, and the allow-list; the swarm config (models, lenses,
|
||||
# concurrency, timeouts) lives centrally in gadfly's review-reusable.yml so it is
|
||||
# tuned in ONE place. Advisory only — never blocks a merge.
|
||||
|
||||
name: Adversarial Review (Gadfly)
|
||||
|
||||
@@ -32,43 +29,26 @@ concurrency:
|
||||
jobs:
|
||||
review:
|
||||
# Security: only trusted users may trigger a secret-bearing run via a PR
|
||||
# comment (pull_request + workflow_dispatch are already trusted). Mirrors
|
||||
# GADFLY_ALLOWED_USERS, the in-container belt-and-suspenders check.
|
||||
# comment (pull_request + workflow_dispatch are already trusted). Mirrors the
|
||||
# allowed_users input below (the in-container belt-and-suspenders check) — both
|
||||
# lists must stay in sync; a workflow if: can't read a workflow_call input.
|
||||
if: >-
|
||||
github.event_name != 'issue_comment'
|
||||
|| (github.event.issue.pull_request
|
||||
&& (github.actor == 'steve'
|
||||
|| github.actor == 'fizi'
|
||||
|| github.actor == 'dazed'))
|
||||
runs-on: ubuntu-latest
|
||||
# 3 cloud models, all concurrent, 3-lens suite. ~12 min typical.
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: docker://gitea.stevedudenhoeffer.com/steve/gadfly:sha-6e3a83c
|
||||
env:
|
||||
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
|
||||
# executus uses CLOUD MODELS ONLY. The local Macs (m1/m5) were dropped:
|
||||
# on a P2-review measurement they took 26–29 min (with lens timeouts)
|
||||
# and contributed ZERO real findings — the two cloud models found every
|
||||
# genuine bug in 6–12 min. Cloud-only is faster AND higher-signal.
|
||||
# 3 cloud models, one consolidated comment each, all run in parallel.
|
||||
GADFLY_MODELS: "minimax-m3:cloud,deepseek-v4-flash:cloud,glm-5.2:cloud"
|
||||
GADFLY_PROVIDER_CONCURRENCY: "ollama-cloud=3"
|
||||
# Default => the 3-lens suite (security, correctness, error-handling).
|
||||
# Set the repo var GADFLY_SPECIALISTS to override (csv / "all" / "auto").
|
||||
GADFLY_SPECIALISTS: ${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }}
|
||||
# Per-lens deadline + bounded steps so the slow local models stay sane.
|
||||
GADFLY_TIMEOUT_SECS: "600"
|
||||
GADFLY_MAX_STEPS: "14"
|
||||
# Allow-list for the comment trigger (mirrors the job-level if: guard).
|
||||
GADFLY_ALLOWED_USERS: "steve,fizi,dazed"
|
||||
# --- event context (leave as-is) ---
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
|
||||
PR_BRANCH: ${{ github.head_ref }}
|
||||
IS_DRAFT: ${{ github.event.pull_request.draft }}
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
# Tracks gadfly's v1 release tag — a curated pointer re-moved on each release
|
||||
# (unlike @main, which moves on every push). Central swarm tuning propagates
|
||||
# here automatically; the tradeoff vs a full sha pin is that v1 is mutable.
|
||||
uses: steve/gadfly/.gitea/workflows/review-reusable.yml@v1
|
||||
# Least privilege: forward only the review secrets (not `secrets: inherit`,
|
||||
# which would expose every repo secret). GITEA_TOKEN is the automatic token.
|
||||
secrets:
|
||||
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
GADFLY_FINDINGS_URL: ${{ secrets.GADFLY_FINDINGS_URL }}
|
||||
GADFLY_FINDINGS_TOKEN: ${{ secrets.GADFLY_FINDINGS_TOKEN }}
|
||||
with:
|
||||
# Consumer-specific allow-list; everything else is inherited.
|
||||
allowed_users: "steve,fizi,dazed"
|
||||
|
||||
@@ -104,6 +104,17 @@ jobs:
|
||||
fi
|
||||
echo "OK: core go.sum is free of host/DB dependencies."
|
||||
|
||||
- name: Light-tier canary imports no battery
|
||||
run: |
|
||||
# examples/reviewer is gadfly's shape on the CORE only. If it ever
|
||||
# pulls in a battery (audit/budget/persona/skill/critic/schedule/
|
||||
# checkpoint/contrib), the light path has regressed.
|
||||
LEAK=$(go list -deps ./examples/reviewer/... | grep -E 'executus/(audit|budget|persona|skill|critic|schedule|checkpoint|contrib)' || true)
|
||||
if [ -n "$LEAK" ]; then
|
||||
echo "ERROR: light-tier canary pulled in a battery:"; echo "$LEAK"; exit 1
|
||||
fi
|
||||
echo "OK: examples/reviewer is core-only."
|
||||
|
||||
- name: contrib/store (nested SQLite module — isolated from core)
|
||||
run: |
|
||||
# contrib/store is a SEPARATE module carrying modernc.org/sqlite; the
|
||||
|
||||
@@ -47,9 +47,10 @@ CORE (majordomo + stdlib):
|
||||
toolbox + majordomo loop + compaction +
|
||||
run-bounding (V10 detached timeout) + step/
|
||||
audit observers + Budget gate; RunnableAgent
|
||||
DTO + nil-safe run.Ports. Follow-ups: wire
|
||||
Critic/Checkpointer/PaletteSource/Delivery,
|
||||
Phases, and the no-tools direct path [P2]
|
||||
DTO + nil-safe run.Ports. Palette delegation +
|
||||
Critic (monitor/deadline/steer) + Delivery
|
||||
WIRED. Follow-ups: Checkpointer (needs a
|
||||
majordomo msg-history hook), Phases [C0c]
|
||||
dispatchguard/ loop/depth/fan-out caps [P0 ✓]
|
||||
pendingattach/ attachment dedupe [P0 ✓]
|
||||
tool/ registry + 3-stage permissions + ssrf [P1 ✓]
|
||||
@@ -58,29 +59,42 @@ CORE (majordomo + stdlib):
|
||||
structured output — no separate structured/ pkg)
|
||||
llmmeta/ shared meta-LLM helper over model/ [P1 ✓]
|
||||
compact/ context compactor (WithCompactor hook) [P2 ✓]
|
||||
tools/{web,net,store,compose,meta,comms} generic tools [P3]
|
||||
tools/ generic tool library: Register (think/now/ [P3 ✓]
|
||||
cite, zero-config) + RegisterMeta (classify/
|
||||
extract_entities/summarize) + RegisterStore
|
||||
(kv_*/file_*, default static quota); seams in
|
||||
research_providers.go/file_storage.go/
|
||||
kv_storage.go/quota_provider.go. End-to-end
|
||||
"agent calls a tool" test green. Remaining
|
||||
(deferred): web/net/compose groups + backends
|
||||
|
||||
BATTERIES (opt-in siblings, each nil-safe + a default):
|
||||
persona/ Agent noun + Storage seam + builtin loader [P4 ~]
|
||||
persona/ Agent noun + Storage seam + builtin loader [P4 ✓]
|
||||
+ ToRunnable() bridge to run.RunnableAgent +
|
||||
Memory default (host: chatbot/commands/personalization)
|
||||
skill/ Skill noun + LEAN SkillStore (lifecycle/ [P4 ~]
|
||||
versions/schedule — NOT mort's 60-method
|
||||
skill/ Skill noun + LEAN SkillStore (lifecycle/ [P4 ✓]
|
||||
versions/schedule, NOT mort's 60-method
|
||||
monster) + ToRunnable + Memory default
|
||||
audit/ run.Audit Sink + Writer + queryable Memory [P4 ✓]
|
||||
default (skillaudit Storage iface; GORM stays in mort)
|
||||
critic/ two-tier timeout state machine + Escalator [P4]
|
||||
schedule/ cron runner cores [P4]
|
||||
checkpoint/ durable resume seam [P4]
|
||||
critic/ two-tier timeout watchdog (run.Critic) + [P4 ✓]
|
||||
Escalator policy seam + ExtendOnce default
|
||||
schedule/ generic cron Runner (Tick/Loop over a wired [P4 ✓]
|
||||
Due/Run/Mark/Next; no cron grammar of its own)
|
||||
checkpoint/ CheckpointStore + run.Checkpointer handle [P4 ✓]
|
||||
(throttled Save/Complete/Fail) + Memory
|
||||
budget/ DBBudget rolling-7d + NoOp (run.Budget); [P4 ✓]
|
||||
BudgetStorage iface + Memory default
|
||||
|
||||
contrib/store/ SECOND module (+ modernc.org/sqlite): [P4 ✓]
|
||||
pure-Go SQLite impls of ALL store seams: budget +
|
||||
persona + skill + audit ✓ (JSON-blob+indexed cols,
|
||||
persona + skill + audit (JSON-blob+indexed cols,
|
||||
round-trip tested). CI proves the driver lands HERE,
|
||||
not in the core go.sum.
|
||||
CI proves the driver lands HERE, not in the core go.sum.
|
||||
|
||||
NOTE: critic/checkpoint executor wiring (run.Ports.Critic /
|
||||
.Checkpointer call sites) is a P2 follow-up — the batteries +
|
||||
defaults exist ahead of that wiring.
|
||||
```
|
||||
|
||||
### The one architectural move
|
||||
@@ -102,7 +116,7 @@ repackaging.
|
||||
|
||||
P0 module + zero-coupling moves + core seams (this) → P1 tool registry + model →
|
||||
P2 run kernel + Ports inversion → P3 generic tools + defaults → P4 persona/skill
|
||||
redesign + batteries + SQLite store → P5 gadfly on core (light-tier canary) → P6
|
||||
redesign + batteries + SQLite store → P5 gadfly-on-core canary (examples/reviewer ✓) → P6
|
||||
rewire mort + tag v0.1.0. The mort-side rewrite reuses mort's existing
|
||||
`mort_*_adapters.go` wall as the host adapter layer.
|
||||
|
||||
|
||||
@@ -48,6 +48,9 @@ bot) — mort and gadfly are the first two consumers (heavy and light). See
|
||||
- `config/`, `deliver/`, `identity/` — host seams (config / output / identity),
|
||||
each with a shipped default.
|
||||
- `dispatchguard/`, `pendingattach/` — run-safety primitives.
|
||||
- `examples/reviewer` — a **gadfly-shaped PR reviewer on the core only** (env-config
|
||||
model fleet → `fanout` N×M swarm → `model.GenerateWith[T]` structured findings →
|
||||
consolidation), the light-tier canary; CI asserts it pulls in no battery.
|
||||
|
||||
## Design
|
||||
|
||||
@@ -61,7 +64,7 @@ Two tiers in one module (`go.mod` = majordomo + stdlib only):
|
||||
ships a default, so you add only what you use.
|
||||
|
||||
Persistence that needs a real database lives in a **separate** nested module
|
||||
(`contrib/store`, pure-Go SQLite — the `budget` store landed first, conformance-tested) so the core never drags in a DB driver — a
|
||||
(`contrib/store`, pure-Go SQLite) so the core never drags in a DB driver — a
|
||||
static-binary host (gadfly) stays static.
|
||||
|
||||
## License
|
||||
|
||||
+34
-9
@@ -99,6 +99,19 @@ func (m *Memory) newestFirst(keep func(SkillRun) bool) []SkillRun {
|
||||
return out
|
||||
}
|
||||
|
||||
// oldestFirst returns the retained runs in insertion (oldest-first) order,
|
||||
// optionally filtered. Caller holds at least RLock.
|
||||
func (m *Memory) oldestFirst(keep func(SkillRun) bool) []SkillRun {
|
||||
out := make([]SkillRun, 0, len(m.order))
|
||||
for _, id := range m.order {
|
||||
r := m.runs[id]
|
||||
if keep == nil || keep(r) {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func page(rs []SkillRun, offset, limit int) []SkillRun {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
@@ -142,10 +155,12 @@ func (m *Memory) ListRunsByCaller(_ context.Context, callerID string, limit int)
|
||||
}
|
||||
|
||||
func (m *Memory) matchesFilter(r SkillRun, f RunFilter) bool {
|
||||
if !f.IncludeDryRun && r.Status == "dry_run" {
|
||||
return false
|
||||
}
|
||||
if f.Status != "" && r.Status != f.Status {
|
||||
if f.Status != "" {
|
||||
if r.Status != f.Status {
|
||||
return false
|
||||
}
|
||||
// An explicit Status (even "dry_run") matches regardless of IncludeDryRun.
|
||||
} else if !f.IncludeDryRun && r.Status == "dry_run" {
|
||||
return false
|
||||
}
|
||||
if f.SkillID != "" && r.SkillID != f.SkillID {
|
||||
@@ -170,6 +185,9 @@ func (m *Memory) matchesFilter(r SkillRun, f RunFilter) bool {
|
||||
}
|
||||
|
||||
func (m *Memory) ListRunsFiltered(_ context.Context, f RunFilter, offset, limit int) ([]SkillRun, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 50 // bound admin scans, per the Storage contract
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return page(m.newestFirst(func(r SkillRun) bool { return m.matchesFilter(r, f) }), offset, limit), nil
|
||||
@@ -203,7 +221,7 @@ func (m *Memory) PurgeOlderThan(_ context.Context, t time.Time) (int64, error) {
|
||||
func (m *Memory) ListChildrenByParent(_ context.Context, parentRunID string) ([]SkillRun, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.newestFirst(func(r SkillRun) bool { return r.ParentRunID == parentRunID }), nil
|
||||
return m.oldestFirst(func(r SkillRun) bool { return r.ParentRunID == parentRunID }), nil
|
||||
}
|
||||
|
||||
func (m *Memory) WalkParentChain(_ context.Context, runID string) ([]SkillRun, error) {
|
||||
@@ -211,7 +229,7 @@ func (m *Memory) WalkParentChain(_ context.Context, runID string) ([]SkillRun, e
|
||||
defer m.mu.RUnlock()
|
||||
var chain []SkillRun
|
||||
seen := map[string]bool{}
|
||||
for id := runID; id != ""; {
|
||||
for id := runID; id != "" && len(chain) < MaxParentChainDepth; {
|
||||
r, ok := m.runs[id]
|
||||
if !ok || seen[id] {
|
||||
break
|
||||
@@ -220,13 +238,20 @@ func (m *Memory) WalkParentChain(_ context.Context, runID string) ([]SkillRun, e
|
||||
chain = append(chain, r)
|
||||
id = r.ParentRunID
|
||||
}
|
||||
// Contract: root first, the queried run last. We walked child→root, so reverse.
|
||||
for i, j := 0, len(chain)-1; i < j; i, j = i+1, j-1 {
|
||||
chain[i], chain[j] = chain[j], chain[i]
|
||||
}
|
||||
return chain, nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListFinishedRunsBefore(_ context.Context, cutoff time.Time, limit int) ([]SkillRun, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil // contract: a real bound is required
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return page(m.newestFirst(func(r SkillRun) bool {
|
||||
return page(m.oldestFirst(func(r SkillRun) bool {
|
||||
return r.FinishedAt != nil && r.FinishedAt.Before(cutoff)
|
||||
}), 0, limit), nil
|
||||
}
|
||||
@@ -244,8 +269,8 @@ func (m *Memory) LastRunBySkills(_ context.Context, skillIDs []string, includeFa
|
||||
if !want[r.SkillID] {
|
||||
continue
|
||||
}
|
||||
if !includeFailed && (r.Status == "error" || r.Status == "timeout") {
|
||||
continue
|
||||
if !includeFailed && r.Status != "ok" {
|
||||
continue // contract: only status=="ok" counts unless includeFailed
|
||||
}
|
||||
if r.StartedAt.After(out[r.SkillID]) {
|
||||
out[r.SkillID] = r.StartedAt
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
)
|
||||
|
||||
// TestOnToolRedactsSecretTools: a secret-bearing tool's args/result must NOT be
|
||||
// persisted verbatim in the audit log.
|
||||
func TestOnToolRedactsSecretTools(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mem := NewMemory()
|
||||
mem.StartRun(ctx, SkillRun{ID: "r1"})
|
||||
w := NewWriter(mem, "r1")
|
||||
|
||||
secret := `{"url":"https://x","headers":{"Authorization":"Bearer SUPERSECRET"}}`
|
||||
w.OnTool(llm.ToolCall{Name: "http_get", ID: "1", Arguments: []byte(secret)}, "TOPSECRETBODY")
|
||||
// a non-secret tool is logged verbatim
|
||||
w.OnTool(llm.ToolCall{Name: "think", ID: "2", Arguments: []byte(`{"thought":"hi"}`)}, "ok")
|
||||
|
||||
logs, _ := mem.ListLogsByRun(ctx, "r1")
|
||||
var dump strings.Builder
|
||||
for _, l := range logs {
|
||||
for k, v := range l.Payload {
|
||||
dump.WriteString(k)
|
||||
dump.WriteString("=")
|
||||
if s, ok := v.(string); ok {
|
||||
dump.WriteString(s)
|
||||
}
|
||||
dump.WriteString(" ")
|
||||
}
|
||||
}
|
||||
all := dump.String()
|
||||
if strings.Contains(all, "SUPERSECRET") || strings.Contains(all, "TOPSECRETBODY") {
|
||||
t.Fatalf("secret leaked into audit log: %s", all)
|
||||
}
|
||||
// the redaction marker is present, and the non-secret tool's args survive
|
||||
foundRedacted, foundThink := false, false
|
||||
for _, l := range logs {
|
||||
if l.EventType == "tool_call" {
|
||||
if r, _ := l.Payload["args_redacted"].(bool); r {
|
||||
foundRedacted = true
|
||||
}
|
||||
if a, _ := l.Payload["args"].(string); strings.Contains(a, "thought") {
|
||||
foundThink = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundRedacted {
|
||||
t.Error("secret tool_call should carry args_redacted=true")
|
||||
}
|
||||
if !foundThink {
|
||||
t.Error("non-secret tool args should be logged verbatim")
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -2,6 +2,7 @@ package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
@@ -36,8 +37,9 @@ func (s *Sink) StartRun(ctx context.Context, info run.RunInfo) run.RunRecorder {
|
||||
if started.IsZero() {
|
||||
started = time.Now()
|
||||
}
|
||||
// Best-effort: a failed StartRun must not break the user-visible run.
|
||||
_ = s.storage.StartRun(ctx, SkillRun{
|
||||
// Best-effort: a failed StartRun must not break the user-visible run, but we
|
||||
// surface it (a swallowed failure leaves orphan log events with no run row).
|
||||
if err := s.storage.StartRun(ctx, SkillRun{
|
||||
ID: info.RunID,
|
||||
SkillID: info.SubjectID,
|
||||
CallerID: info.CallerID,
|
||||
@@ -46,7 +48,10 @@ func (s *Sink) StartRun(ctx context.Context, info run.RunInfo) run.RunRecorder {
|
||||
Inputs: info.Inputs,
|
||||
StartedAt: started,
|
||||
Status: "running",
|
||||
})
|
||||
}); err != nil {
|
||||
slog.Warn("audit: StartRun failed; the run row is missing so its log events will orphan",
|
||||
"run_id", info.RunID, "error", err)
|
||||
}
|
||||
return &recorder{w: NewWriter(s.storage, info.RunID)}
|
||||
}
|
||||
|
||||
|
||||
+43
-6
@@ -168,16 +168,26 @@ func (w *Writer) OnStep(iter int, resp *llm.Response) {
|
||||
// surrounding narration could leak a secret (MCP args, email body/
|
||||
// recipients, raw HTTP request). Mirrors the steps.go redaction list so
|
||||
// the audit trace never persists secret-adjacent assistant text.
|
||||
// isSecretTool reports whether a tool's arguments/results may carry secrets
|
||||
// (MCP args, email bodies/recipients, HTTP auth headers/bodies) and so must be
|
||||
// redacted from the persisted audit log. Single source of truth for both the
|
||||
// step-narration redaction and the OnTool arg/result redaction. NOTE: this is
|
||||
// a name-prefix allowlist — a NEW secret-bearing tool must be added here or its
|
||||
// args/results will be logged verbatim.
|
||||
func isSecretTool(name string) bool {
|
||||
switch name {
|
||||
case "mcp_call", "email_send":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(name, "http_")
|
||||
}
|
||||
|
||||
func stepHasSecretTool(resp *llm.Response) bool {
|
||||
if resp == nil {
|
||||
return false
|
||||
}
|
||||
for _, c := range resp.ToolCalls {
|
||||
switch c.Name {
|
||||
case "mcp_call", "email_send":
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(c.Name, "http_") {
|
||||
if isSecretTool(c.Name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -211,6 +221,24 @@ func (w *Writer) OnTool(call llm.ToolCall, result string) {
|
||||
return
|
||||
}
|
||||
w.calls.Add(1)
|
||||
// Redact the args/result of secret-bearing tools — these fields actually
|
||||
// CARRY the secret (MCP args, email body/recipients, HTTP auth/body), so
|
||||
// logging them verbatim would defeat the OnStep narration redaction.
|
||||
if isSecretTool(call.Name) {
|
||||
w.appendLog("tool_call", map[string]any{
|
||||
"name": call.Name,
|
||||
"id": call.ID,
|
||||
"args_redacted": true,
|
||||
"args_len": len(call.Arguments),
|
||||
})
|
||||
w.appendLog("tool_result", map[string]any{
|
||||
"name": call.Name,
|
||||
"id": call.ID,
|
||||
"result_redacted": true,
|
||||
"result_len": len(result),
|
||||
})
|
||||
return
|
||||
}
|
||||
w.appendLog("tool_call", map[string]any{
|
||||
"name": call.Name,
|
||||
"args": string(call.Arguments),
|
||||
@@ -296,6 +324,10 @@ func (w *Writer) Close(ctx context.Context, stats RunStats) {
|
||||
// hung connection that the run goroutine shouldn't keep waiting on.
|
||||
const auditFinishTimeout = 10 * time.Second
|
||||
|
||||
// auditAppendTimeout bounds each per-event AppendLog on the hot path so a hung
|
||||
// storage backend can't block the run goroutine.
|
||||
const auditAppendTimeout = 3 * time.Second
|
||||
|
||||
// ToolCallsCount returns how many tool invocations OnTool has seen so
|
||||
// far. Useful for budget enforcement.
|
||||
func (w *Writer) ToolCallsCount() int { return int(w.calls.Load()) }
|
||||
@@ -309,7 +341,12 @@ func (w *Writer) appendLog(eventType string, payload map[string]any) {
|
||||
Payload: payload,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := w.storage.AppendLog(context.Background(), log); err != nil {
|
||||
// Bound the write: a hung storage backend must not block the run goroutine
|
||||
// on the hot path (every step/tool event flows through here). Detached from
|
||||
// any caller deadline — the log write is independent of the run's context.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), auditAppendTimeout)
|
||||
defer cancel()
|
||||
if err := w.storage.AppendLog(ctx, log); err != nil {
|
||||
slog.Warn("skillaudit: AppendLog failed", "run_id", w.runID, "seq", seq, "type", eventType, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,4 +1,6 @@
|
||||
// Package skillexec runs saved Skill definitions via majordomo's agent
|
||||
// Package budget gates and meters per-caller resource use over a rolling
|
||||
// 7-day window (run.Ports.Budget). DBBudget is the durable tracker; NoOpBudget
|
||||
// disables metering; the BudgetStorage seam backs it (Memory / contrib SQLite).
|
||||
// loop (gitea.stevedudenhoeffer.com/steve/majordomo/agent).
|
||||
//
|
||||
// Why: a Skill is data; the executor turns data into a running agent
|
||||
@@ -130,7 +132,7 @@ func (b *DBBudget) Check(ctx context.Context, callerID string) error {
|
||||
return fmt.Errorf("budget: %w", err)
|
||||
}
|
||||
if bud != nil {
|
||||
if b.now().Sub(bud.WindowStart) < 7*24*time.Hour {
|
||||
if b.now().Sub(bud.WindowStart) < budgetWindow {
|
||||
cap := b.weeklyLimit()
|
||||
if cap > 0 && bud.SecondsUsed >= cap {
|
||||
if b.notify != nil {
|
||||
|
||||
+3
-3
@@ -10,7 +10,7 @@ import (
|
||||
// usage held in memory (lost on restart). The default behind DBBudget for a
|
||||
// light host or tests; mort uses its GORM Storage, contrib/store adds SQLite.
|
||||
type Memory struct {
|
||||
mu sync.Mutex
|
||||
mu sync.RWMutex
|
||||
rows map[string]*SkillBudget
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ var _ BudgetStorage = (*Memory)(nil)
|
||||
func (m *Memory) Initialize(context.Context) error { return nil }
|
||||
|
||||
func (m *Memory) Get(_ context.Context, userID string) (*SkillBudget, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
r, ok := m.rows[userID]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Package checkpoint is the durable-resume battery: it persists a run's
|
||||
// resumable progress so a run interrupted by a shutdown can be recovered and
|
||||
// continued on the next boot, rather than silently lost. It plugs into
|
||||
// run.Ports.Checkpointer.
|
||||
//
|
||||
// Mort backs CheckpointStore with its durable-job table; Memory() is the
|
||||
// zero-dependency default; contrib/store can add a SQLite one. NOTE: the
|
||||
// executor's call into run.Ports.Checkpointer is a P2 follow-up — this battery
|
||||
// provides the seam + impls ahead of that wiring.
|
||||
package checkpoint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
)
|
||||
|
||||
// RunCheckpointMeta is the run attribution needed to resume a run from scratch
|
||||
// (mirrors mort's agentexec.RunCheckpointMeta).
|
||||
type RunCheckpointMeta struct {
|
||||
RunID string
|
||||
AgentID string
|
||||
AgentName string
|
||||
CallerID string
|
||||
ChannelID string
|
||||
GuildID string
|
||||
Prompt string
|
||||
ModelTier string
|
||||
ParentRunID string
|
||||
}
|
||||
|
||||
// RunCheckpoint is one persisted snapshot of a run's resumable progress.
|
||||
type RunCheckpoint struct {
|
||||
Meta RunCheckpointMeta
|
||||
Messages []llm.Message // conversation so far
|
||||
Iteration int // completed agent-loop iterations
|
||||
ActivePhase string // current phase name (multi-phase agents); "" otherwise
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// CheckpointStore persists run checkpoints keyed by run id. A live checkpoint
|
||||
// means "this run was in flight and not cleanly finished"; Complete/Fail delete
|
||||
// it. ListInterrupted returns every surviving checkpoint at boot for recovery.
|
||||
type CheckpointStore interface {
|
||||
Save(ctx context.Context, cp RunCheckpoint) error
|
||||
Load(ctx context.Context, runID string) (*RunCheckpoint, error)
|
||||
Delete(ctx context.Context, runID string) error
|
||||
ListInterrupted(ctx context.Context) ([]RunCheckpoint, error)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package checkpoint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/run"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
)
|
||||
|
||||
func TestHandleSaveCompleteDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mem := NewMemory()
|
||||
meta := RunCheckpointMeta{RunID: "r1", AgentID: "a1", CallerID: "c1"}
|
||||
cp := New(mem, meta, 0, nil) // throttle 0 = save every call
|
||||
|
||||
if err := cp.Save(ctx, run.RunCheckpointState{Messages: []llm.Message{{Role: "user"}}, Iteration: 2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := mem.Load(ctx, "r1")
|
||||
if got == nil || got.Iteration != 2 || got.Meta.AgentID != "a1" {
|
||||
t.Fatalf("checkpoint not persisted: %+v", got)
|
||||
}
|
||||
if il, _ := mem.ListInterrupted(ctx); len(il) != 1 {
|
||||
t.Errorf("ListInterrupted = %d, want 1 (in-flight)", len(il))
|
||||
}
|
||||
// Complete clears it (no longer a recovery candidate).
|
||||
if err := cp.Complete(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if il, _ := mem.ListInterrupted(ctx); len(il) != 0 {
|
||||
t.Errorf("after Complete, ListInterrupted = %d, want 0", len(il))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleThrottle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mem := NewMemory()
|
||||
now := time.Now()
|
||||
cp := New(mem, RunCheckpointMeta{RunID: "r"}, time.Minute, func() time.Time { return now })
|
||||
|
||||
cp.Save(ctx, run.RunCheckpointState{Iteration: 1})
|
||||
now = now.Add(10 * time.Second) // within throttle window
|
||||
cp.Save(ctx, run.RunCheckpointState{Iteration: 2})
|
||||
if got, _ := mem.Load(ctx, "r"); got.Iteration != 1 {
|
||||
t.Errorf("throttled save should keep iteration 1, got %d", got.Iteration)
|
||||
}
|
||||
now = now.Add(time.Minute) // past throttle
|
||||
cp.Save(ctx, run.RunCheckpointState{Iteration: 3})
|
||||
if got, _ := mem.Load(ctx, "r"); got.Iteration != 3 {
|
||||
t.Errorf("post-throttle save should land iteration 3, got %d", got.Iteration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilStoreNoop(t *testing.T) {
|
||||
cp := New(nil, RunCheckpointMeta{RunID: "r"}, 0, nil)
|
||||
if err := cp.Save(context.Background(), run.RunCheckpointState{}); err != nil {
|
||||
t.Errorf("nil-store Save should be a no-op, got %v", err)
|
||||
}
|
||||
if err := cp.Complete(context.Background()); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package checkpoint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/run"
|
||||
)
|
||||
|
||||
// handle is a per-run run.Checkpointer bound to one run's id + meta. Save writes
|
||||
// a fresh snapshot (throttled), Complete/Fail delete the checkpoint (a cleanly
|
||||
// finished or terminally failed run is NOT a recovery candidate). A run
|
||||
// interrupted by shutdown never calls Complete/Fail, so its checkpoint survives
|
||||
// for ListInterrupted at boot.
|
||||
type handle struct {
|
||||
store CheckpointStore
|
||||
meta RunCheckpointMeta
|
||||
throttle time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
lastSave time.Time
|
||||
}
|
||||
|
||||
var _ run.Checkpointer = (*handle)(nil)
|
||||
|
||||
// New returns a run.Checkpointer that persists snapshots of the run identified
|
||||
// by meta.RunID to store, no more often than throttle (Save calls inside the
|
||||
// window are skipped). A nil store yields a no-op Checkpointer. throttle <= 0
|
||||
// saves every call; now defaults to time.Now.
|
||||
func New(store CheckpointStore, meta RunCheckpointMeta, throttle time.Duration, now func() time.Time) run.Checkpointer {
|
||||
if store == nil {
|
||||
return noop{}
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &handle{store: store, meta: meta, throttle: throttle, now: now}
|
||||
}
|
||||
|
||||
func (h *handle) Save(ctx context.Context, st run.RunCheckpointState) error {
|
||||
h.mu.Lock()
|
||||
now := h.now()
|
||||
if h.throttle > 0 && !h.lastSave.IsZero() && now.Sub(h.lastSave) < h.throttle {
|
||||
h.mu.Unlock()
|
||||
return nil // throttled — a more recent snapshot will land shortly
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
// Advance the throttle clock only AFTER a successful persist. If the store
|
||||
// write fails, lastSave stays put so the next Save isn't throttled away —
|
||||
// otherwise a transient store error would silently drop the snapshot the
|
||||
// caller believes was saved. (A run drives one Save goroutine, so the brief
|
||||
// unguarded window here can't double-write.)
|
||||
if err := h.store.Save(ctx, RunCheckpoint{
|
||||
Meta: h.meta,
|
||||
Messages: st.Messages,
|
||||
Iteration: st.Iteration,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
h.mu.Lock()
|
||||
if now.After(h.lastSave) {
|
||||
h.lastSave = now
|
||||
}
|
||||
h.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *handle) Complete(ctx context.Context) error { return h.store.Delete(ctx, h.meta.RunID) }
|
||||
|
||||
func (h *handle) Fail(ctx context.Context, _ error) error { return h.store.Delete(ctx, h.meta.RunID) }
|
||||
|
||||
// noop is the nil-store Checkpointer: every method is a successful no-op.
|
||||
type noop struct{}
|
||||
|
||||
var _ run.Checkpointer = noop{}
|
||||
|
||||
func (noop) Save(context.Context, run.RunCheckpointState) error { return nil }
|
||||
func (noop) Complete(context.Context) error { return nil }
|
||||
func (noop) Fail(context.Context, error) error { return nil }
|
||||
@@ -0,0 +1,55 @@
|
||||
package checkpoint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Memory is a zero-dependency in-process CheckpointStore. NOTE: an in-memory
|
||||
// checkpoint store does NOT survive the process restart it exists to recover
|
||||
// from — it is the test/light-host default and makes ListInterrupted meaningful
|
||||
// only within a single process lifetime. A host that wants real
|
||||
// crash-recovery wires a durable CheckpointStore (mort's durable-job table).
|
||||
type Memory struct {
|
||||
mu sync.RWMutex
|
||||
cps map[string]RunCheckpoint // by run id
|
||||
}
|
||||
|
||||
// NewMemory returns an empty in-memory CheckpointStore.
|
||||
func NewMemory() *Memory { return &Memory{cps: map[string]RunCheckpoint{}} }
|
||||
|
||||
var _ CheckpointStore = (*Memory)(nil)
|
||||
|
||||
func (m *Memory) Save(_ context.Context, cp RunCheckpoint) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.cps[cp.Meta.RunID] = cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) Load(_ context.Context, runID string) (*RunCheckpoint, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
cp, ok := m.cps[runID]
|
||||
if !ok {
|
||||
return nil, nil // no checkpoint (not an error — the run finished cleanly or never started)
|
||||
}
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *Memory) Delete(_ context.Context, runID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.cps, runID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListInterrupted(_ context.Context) ([]RunCheckpoint, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]RunCheckpoint, 0, len(m.cps))
|
||||
for _, cp := range m.cps {
|
||||
out = append(out, cp)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/budget"
|
||||
@@ -57,6 +58,11 @@ func (s *budgetStore) Get(ctx context.Context, userID string) (*budget.SkillBudg
|
||||
// Add increments usage atomically, rolling the 7-day window over inside one
|
||||
// transaction so concurrent Adds can't race the read-modify-write.
|
||||
func (s *budgetStore) Add(ctx context.Context, userID string, secondsUsed float64, now time.Time) error {
|
||||
// A NaN/Inf would poison the seconds_used column irrecoverably (NaN
|
||||
// propagates through every later add), so reject it at the boundary.
|
||||
if math.IsNaN(secondsUsed) || math.IsInf(secondsUsed, 0) {
|
||||
return fmt.Errorf("budgetStore.Add: invalid secondsUsed %v", secondsUsed)
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("budgetStore.Add: begin: %w", err)
|
||||
|
||||
@@ -157,11 +157,18 @@ func (s *personaStore) ListScheduledAgents(ctx context.Context, dueBefore time.T
|
||||
}
|
||||
|
||||
func (s *personaStore) MarkAgentScheduledRun(ctx context.Context, agentID string, ranAt, nextAt time.Time) error {
|
||||
a, err := s.GetAgent(ctx, agentID)
|
||||
// Single atomic statement, not Get→mutate→Save: closes the lost-update
|
||||
// window a concurrent Mark/edit would otherwise open. json_set keeps the
|
||||
// blob's *time.Time fields consistent with the next_run_at column (Go
|
||||
// encodes time.Time as RFC3339Nano, so it round-trips through GetAgent).
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE agents SET next_run_at=?, data=json_set(data,'$.NextRunAt',?,'$.LastScheduledRunAt',?) WHERE id=?`,
|
||||
nextAt.Unix(), nextAt.Format(time.RFC3339Nano), ranAt.Format(time.RFC3339Nano), agentID)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("personaStore.MarkAgentScheduledRun: %w", err)
|
||||
}
|
||||
a.LastScheduledRunAt = &ranAt
|
||||
a.NextRunAt = &nextAt
|
||||
return s.SaveAgent(ctx, a)
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return persona.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -69,3 +69,38 @@ func TestSQLitePersonaStore(t *testing.T) {
|
||||
t.Errorf("GetAgent after delete = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkAgentScheduledRunBlobRoundTrips guards the json_set atomic update:
|
||||
// the JSON blob must stay parseable and reflect the new scheduled times.
|
||||
func TestMarkAgentScheduledRunBlobRoundTrips(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, _ := Open(":memory:")
|
||||
defer db.Close()
|
||||
st := db.Personas()
|
||||
st.InitializeAgentStorage(ctx)
|
||||
start := time.Now().UTC()
|
||||
a := &persona.Agent{ID: "m1", Name: "n", OwnerID: "o", Schedule: "0 * * * *"}
|
||||
a.NextRunAt = &start
|
||||
if err := st.SaveAgent(ctx, a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ran := start
|
||||
next := start.Add(time.Hour)
|
||||
if err := st.MarkAgentScheduledRun(ctx, "m1", ran, next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.GetAgent(ctx, "m1") // blob must still unmarshal
|
||||
if err != nil {
|
||||
t.Fatalf("GetAgent after json_set Mark failed (blob corrupt?): %v", err)
|
||||
}
|
||||
if got.NextRunAt == nil || !got.NextRunAt.Equal(next) {
|
||||
t.Errorf("blob NextRunAt = %v, want %v", got.NextRunAt, next)
|
||||
}
|
||||
if got.LastScheduledRunAt == nil || !got.LastScheduledRunAt.Equal(ran) {
|
||||
t.Errorf("blob LastScheduledRunAt = %v, want %v", got.LastScheduledRunAt, ran)
|
||||
}
|
||||
// Unknown id -> ErrNotFound.
|
||||
if err := st.MarkAgentScheduledRun(ctx, "nope", ran, next); err != persona.ErrNotFound {
|
||||
t.Errorf("Mark(unknown) = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ CREATE TABLE IF NOT EXISTS skill_versions (
|
||||
seq INTEGER NOT NULL, -- append order, for newest-first
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_versions_skill ON skill_versions(skill_id, seq);`)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_skill_versions_skill ON skill_versions(skill_id, seq);`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("skillStore.Initialize: %w", err)
|
||||
}
|
||||
@@ -182,27 +182,56 @@ func (s *skillStore) ListDueScheduled(ctx context.Context, now time.Time) ([]ski
|
||||
}
|
||||
|
||||
func (s *skillStore) MarkScheduledRun(ctx context.Context, skillID string, ranAt, nextAt time.Time) error {
|
||||
sk, err := s.Get(ctx, skillID)
|
||||
if err != nil {
|
||||
return err
|
||||
// Single atomic statement instead of Get→mutate→Save: a concurrent Mark or
|
||||
// admin edit can't lose this update (no read-modify-write window). json_set
|
||||
// keeps the JSON blob's NextRunAt/LastScheduledRunAt consistent with the
|
||||
// indexed next_run_at column; RFC3339Nano matches Go's time JSON encoding so
|
||||
// the blob still round-trips through Get.
|
||||
var next int64
|
||||
if !nextAt.IsZero() {
|
||||
next = nextAt.Unix()
|
||||
}
|
||||
sk.LastScheduledRunAt = ranAt
|
||||
sk.NextRunAt = nextAt
|
||||
return s.Save(ctx, sk)
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE skills SET next_run_at=?, data=json_set(data,'$.NextRunAt',?,'$.LastScheduledRunAt',?) WHERE id=?`,
|
||||
next, nextAt.Format(time.RFC3339Nano), ranAt.Format(time.RFC3339Nano), skillID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("skillStore.MarkScheduledRun: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return skill.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *skillStore) AppendVersion(ctx context.Context, sv skill.SkillVersion) error {
|
||||
if sv.SkillID == "" {
|
||||
return fmt.Errorf("skillStore.AppendVersion: skill_id is required")
|
||||
}
|
||||
blob, err := json.Marshal(sv)
|
||||
if err != nil {
|
||||
return fmt.Errorf("skillStore.AppendVersion: marshal: %w", err)
|
||||
}
|
||||
// seq = current max+1 for this skill (newest-first ordering key).
|
||||
// seq = current max+1 for this skill (newest-first ordering key). The
|
||||
// MAX-then-INSERT runs in ONE transaction and the (skill_id, seq) index is
|
||||
// UNIQUE, so two concurrent appends can't both land the same seq: the loser
|
||||
// fails loudly on commit instead of silently corrupting the ordering. The
|
||||
// Scan error is propagated (was swallowed, leaving seq=0 on failure).
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("skillStore.AppendVersion: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op after Commit
|
||||
var seq int64
|
||||
_ = s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(seq),0)+1 FROM skill_versions WHERE skill_id = ?`, sv.SkillID).Scan(&seq)
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(seq),0)+1 FROM skill_versions WHERE skill_id = ?`, sv.SkillID).Scan(&seq); err != nil {
|
||||
return fmt.Errorf("skillStore.AppendVersion: seq: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO skill_versions (id, skill_id, version, seq, data) VALUES (?, ?, ?, ?, ?)`,
|
||||
sv.ID, sv.SkillID, sv.Version, seq, string(blob)); err != nil {
|
||||
return fmt.Errorf("skillStore.AppendVersion: %w", err)
|
||||
return fmt.Errorf("skillStore.AppendVersion: insert: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("skillStore.AppendVersion: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@ func Open(dsn string) (*DB, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: open %q: %w", dsn, err)
|
||||
}
|
||||
// A contended writer should WAIT for the lock, not fail immediately — set a
|
||||
// busy_timeout so concurrent stores don't see spurious SQLITE_BUSY. (The
|
||||
// doc example advertised this; it's now actually applied for every DSN.)
|
||||
if _, err := sqldb.Exec("PRAGMA busy_timeout=5000"); err != nil {
|
||||
sqldb.Close()
|
||||
return nil, fmt.Errorf("store: set busy_timeout %q: %w", dsn, err)
|
||||
}
|
||||
if err := sqldb.Ping(); err != nil {
|
||||
sqldb.Close()
|
||||
return nil, fmt.Errorf("store: ping %q: %w", dsn, err)
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
// Package critic is the run-watchdog battery: a two-tier timeout monitor that
|
||||
// catches a run that has stopped making progress. It plugs into
|
||||
// run.Ports.Critic.
|
||||
//
|
||||
// The split of concerns is deliberate. executus owns the deterministic
|
||||
// MECHANICS — track activity, fire on a soft timeout, enforce a hard-kill
|
||||
// backstop, carry steer messages and the extendable deadline back to the
|
||||
// executor. The POLICY — what to actually do when a run stalls (nudge it,
|
||||
// extend its deadline, kill it, escalate to a human) — is the Escalator seam.
|
||||
// Mort plugs its LLM critic-agent in as an Escalator; ExtendOnce is the
|
||||
// zero-dependency default.
|
||||
//
|
||||
// The executor wires run.Ports.Critic (C0b): it feeds the handle activity,
|
||||
// binds the run context to its extendable Deadline, drains its Steer, and polls
|
||||
// MaxSteps each step so an Escalator can also raise a long run's step ceiling
|
||||
// (Decision.RaiseStepsBy).
|
||||
package critic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/run"
|
||||
)
|
||||
|
||||
// Progress is the snapshot the critic hands an Escalator when a run stalls.
|
||||
type Progress struct {
|
||||
Iterations int // completed agent-loop iterations so far
|
||||
LastActivity time.Time // wall-clock of the last step/tool event
|
||||
Idle time.Duration // now - LastActivity
|
||||
LastTool string // name of the most recently started tool ("" if none)
|
||||
}
|
||||
|
||||
// Decision is the Escalator's verdict for a stalled run. Zero value = do
|
||||
// nothing (let the hard backstop eventually kill a truly hung run).
|
||||
type Decision struct {
|
||||
Nudge []llm.Message // injected before the agent's next turn (a steer)
|
||||
ExtendBy time.Duration // push the hard deadline out by this much
|
||||
RaiseStepsBy int // raise the run's tool-dispatch step ceiling by this
|
||||
Kill bool // cancel the run now
|
||||
KillReason string
|
||||
}
|
||||
|
||||
// Escalator decides what to do when a run crosses its soft timeout. It is
|
||||
// called at most once per idle period (a fresh step/tool event re-arms it).
|
||||
type Escalator interface {
|
||||
OnSoftTimeout(ctx context.Context, info run.RunInfo, p Progress) Decision
|
||||
}
|
||||
|
||||
// ExtendOnce is the default Escalator: the first time a given run stalls it
|
||||
// extends that run's deadline by By (giving a slow-but-healthy run room), then
|
||||
// takes no further action for it — so a genuinely hung run is later killed by
|
||||
// the hard backstop. A nil/zero By falls back to one soft-timeout's worth.
|
||||
//
|
||||
// The one-shot is keyed PER RUN (by RunInfo.RunID): a single System shares one
|
||||
// ExtendOnce across every run it monitors, so a global flag would let only the
|
||||
// first run to stall ever get its extension. The fired set grows with the
|
||||
// number of distinct runs that stall — fine for a process's run volume; a host
|
||||
// running unboundedly long can construct a fresh System periodically.
|
||||
type ExtendOnce struct {
|
||||
By time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
fired map[string]bool // run ids that have already had their one extension
|
||||
}
|
||||
|
||||
// OnSoftTimeout implements Escalator.
|
||||
func (e *ExtendOnce) OnSoftTimeout(_ context.Context, info run.RunInfo, p Progress) Decision {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.fired[info.RunID] {
|
||||
return Decision{}
|
||||
}
|
||||
if e.fired == nil {
|
||||
e.fired = map[string]bool{}
|
||||
}
|
||||
e.fired[info.RunID] = true
|
||||
by := e.By
|
||||
if by <= 0 {
|
||||
by = p.Idle // ~one soft timeout
|
||||
}
|
||||
return Decision{ExtendBy: by}
|
||||
}
|
||||
|
||||
// System implements run.Critic. Construct with New; one System monitors many
|
||||
// runs concurrently (each Monitor returns an independent handle).
|
||||
type System struct {
|
||||
esc Escalator
|
||||
backstopMul float64 // hard deadline = softTimeout * backstopMul from start
|
||||
checkInterval time.Duration
|
||||
now func() time.Time
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (s *System) log() *slog.Logger {
|
||||
if s.logger != nil {
|
||||
return s.logger
|
||||
}
|
||||
return slog.Default()
|
||||
}
|
||||
|
||||
// New builds a run.Critic. esc is the policy (nil → ExtendOnce). backstopMul is
|
||||
// the hard-kill backstop as a multiple of each run's soft timeout (<=1 → 3). A
|
||||
// nil esc + the default backstop gives a safe "extend once, then hard-kill"
|
||||
// watchdog with no host wiring.
|
||||
func New(esc Escalator, backstopMul float64) *System {
|
||||
if esc == nil {
|
||||
esc = &ExtendOnce{}
|
||||
}
|
||||
if backstopMul <= 1 {
|
||||
backstopMul = 3
|
||||
}
|
||||
return &System{esc: esc, backstopMul: backstopMul, now: time.Now}
|
||||
}
|
||||
|
||||
var _ run.Critic = (*System)(nil)
|
||||
|
||||
// Monitor starts watching a run and returns its handle. Implements run.Critic.
|
||||
func (s *System) Monitor(ctx context.Context, info run.RunInfo, softTimeout time.Duration) run.CriticHandle {
|
||||
if softTimeout <= 0 {
|
||||
return run.CriticHandle(nil) // no soft timeout → not monitored
|
||||
}
|
||||
now := s.now()
|
||||
check := s.checkInterval
|
||||
if check <= 0 {
|
||||
check = softTimeout / 2
|
||||
if check < time.Second {
|
||||
check = time.Second
|
||||
}
|
||||
}
|
||||
h := &handle{
|
||||
sys: s,
|
||||
info: info,
|
||||
softTimeout: softTimeout,
|
||||
now: s.now,
|
||||
lastActivity: now,
|
||||
deadline: now.Add(time.Duration(float64(softTimeout) * s.backstopMul)),
|
||||
maxSteps: info.MaxIterations, // base ceiling; an Escalator may RaiseStepsBy
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
go h.watch(ctx, check)
|
||||
return h
|
||||
}
|
||||
|
||||
// handle is one run's live critic link. Implements run.CriticHandle.
|
||||
type handle struct {
|
||||
sys *System
|
||||
info run.RunInfo
|
||||
softTimeout time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
lastActivity time.Time
|
||||
escalatedAt time.Time // lastActivity value we last escalated for (de-dupes per idle period)
|
||||
deadline time.Time
|
||||
steer []llm.Message
|
||||
iterations int
|
||||
maxSteps int // current tool-dispatch ceiling (base MaxIterations, raised by RaiseStepsBy)
|
||||
lastTool string
|
||||
killed bool // sticky: once an Escalator kills, no later decision un-kills it
|
||||
killCause error // non-nil once killed; surfaced via KillCause for "killed" status
|
||||
stopped bool
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func (h *handle) RecordStep(iter int, _ *llm.Response) {
|
||||
// This battery's Progress tracks iteration count + activity, not per-step
|
||||
// payload, so the response is unused here; a richer Escalator could record it.
|
||||
h.mu.Lock()
|
||||
h.iterations = iter
|
||||
h.lastActivity = h.now()
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *handle) RecordToolStart(name, _ string) {
|
||||
h.mu.Lock()
|
||||
h.lastTool = name
|
||||
h.lastActivity = h.now()
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *handle) Steer() []llm.Message {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if len(h.steer) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := h.steer
|
||||
h.steer = nil
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *handle) Deadline() time.Time {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.deadline
|
||||
}
|
||||
|
||||
func (h *handle) MaxSteps() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.maxSteps
|
||||
}
|
||||
|
||||
func (h *handle) KillCause() error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.killCause
|
||||
}
|
||||
|
||||
func (h *handle) Stop() {
|
||||
h.mu.Lock()
|
||||
if !h.stopped {
|
||||
h.stopped = true
|
||||
close(h.stopCh)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// watch fires the Escalator once per idle period the run crosses its soft
|
||||
// timeout, and applies the returned Decision.
|
||||
func (h *handle) watch(ctx context.Context, interval time.Duration) {
|
||||
// A misbehaving Escalator that panics must not silently kill the watch
|
||||
// goroutine (which would leave the run unmonitored for its lifetime). Log
|
||||
// and exit cleanly — the run falls back to the deadline already set.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
h.sys.log().Error("critic watch panicked; run is now unmonitored", "run", h.info.RunID, "panic", r)
|
||||
}
|
||||
}()
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-h.stopCh:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
h.tick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handle) tick(ctx context.Context) {
|
||||
h.mu.Lock()
|
||||
// Kill is sticky: once an Escalator has killed this run, no later tick (and
|
||||
// no later Decision) un-collapses the deadline.
|
||||
if h.killed {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
idle := h.now().Sub(h.lastActivity)
|
||||
// Only escalate once per idle period: skip if we already escalated for this
|
||||
// exact lastActivity (a fresh step/tool updates lastActivity and re-arms).
|
||||
if idle < h.softTimeout || h.escalatedAt.Equal(h.lastActivity) {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
h.escalatedAt = h.lastActivity
|
||||
snap := Progress{Iterations: h.iterations, LastActivity: h.lastActivity, Idle: idle, LastTool: h.lastTool}
|
||||
h.mu.Unlock()
|
||||
|
||||
d := h.sys.esc.OnSoftTimeout(ctx, h.info, snap)
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.killed { // a concurrent tick may have killed while OnSoftTimeout ran
|
||||
return
|
||||
}
|
||||
if d.Kill {
|
||||
h.killed = true
|
||||
reason := d.KillReason
|
||||
if reason == "" {
|
||||
reason = "critic killed the run"
|
||||
}
|
||||
h.killCause = errors.New(reason) // surfaced via KillCause → "killed" status
|
||||
h.deadline = h.now() // immediate hard deadline → executor cancels
|
||||
return // ignore any Nudge/ExtendBy paired with a Kill
|
||||
}
|
||||
if len(d.Nudge) > 0 {
|
||||
h.steer = append(h.steer, d.Nudge...)
|
||||
}
|
||||
if d.ExtendBy > 0 {
|
||||
h.deadline = h.deadline.Add(d.ExtendBy)
|
||||
}
|
||||
if d.RaiseStepsBy > 0 {
|
||||
// Overflow-safe: a buggy Escalator returning a huge delta must not wrap
|
||||
// maxSteps negative (which the executor would read as "defer to base").
|
||||
if d.RaiseStepsBy > math.MaxInt-h.maxSteps {
|
||||
h.maxSteps = math.MaxInt
|
||||
} else {
|
||||
h.maxSteps += d.RaiseStepsBy
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package critic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/run"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
)
|
||||
|
||||
// escFunc adapts a func to an Escalator.
|
||||
type escFunc func(context.Context, run.RunInfo, Progress) Decision
|
||||
|
||||
func (f escFunc) OnSoftTimeout(ctx context.Context, i run.RunInfo, p Progress) Decision {
|
||||
return f(ctx, i, p)
|
||||
}
|
||||
|
||||
func TestMonitorEscalatesOncePerIdlePeriodAndExtends(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var calls int
|
||||
esc := escFunc(func(_ context.Context, _ run.RunInfo, p Progress) Decision {
|
||||
mu.Lock()
|
||||
calls++
|
||||
mu.Unlock()
|
||||
return Decision{ExtendBy: 50 * time.Millisecond, Nudge: []llm.Message{{Role: llm.RoleUser}}}
|
||||
})
|
||||
s := New(esc, 3)
|
||||
s.checkInterval = 5 * time.Millisecond
|
||||
h := s.Monitor(context.Background(), run.RunInfo{RunID: "r"}, 20*time.Millisecond)
|
||||
defer h.Stop()
|
||||
|
||||
d0 := h.Deadline()
|
||||
time.Sleep(60 * time.Millisecond) // cross the soft timeout with no activity
|
||||
mu.Lock()
|
||||
c := calls
|
||||
mu.Unlock()
|
||||
if c < 1 {
|
||||
t.Fatalf("expected at least one escalation, got %d", c)
|
||||
}
|
||||
// Nudge was queued and is drained once.
|
||||
if msgs := h.Steer(); len(msgs) == 0 {
|
||||
t.Error("expected a queued steer nudge")
|
||||
}
|
||||
if msgs := h.Steer(); len(msgs) != 0 {
|
||||
t.Error("steer should drain (be empty on second read)")
|
||||
}
|
||||
// Deadline was extended.
|
||||
if !h.Deadline().After(d0) {
|
||||
t.Error("deadline should have been extended past the original")
|
||||
}
|
||||
// A fresh step re-arms; another idle period escalates again.
|
||||
h.RecordStep(1, nil)
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
mu.Lock()
|
||||
c2 := calls
|
||||
mu.Unlock()
|
||||
if c2 <= c {
|
||||
t.Errorf("a re-armed idle period should escalate again (%d -> %d)", c, c2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKillCollapsesDeadline(t *testing.T) {
|
||||
esc := escFunc(func(context.Context, run.RunInfo, Progress) Decision {
|
||||
return Decision{Kill: true, KillReason: "hung"}
|
||||
})
|
||||
s := New(esc, 10) // big backstop so only Kill collapses it
|
||||
s.checkInterval = 5 * time.Millisecond
|
||||
h := s.Monitor(context.Background(), run.RunInfo{RunID: "r"}, 20*time.Millisecond)
|
||||
defer h.Stop()
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
if h.Deadline().After(time.Now().Add(time.Second)) {
|
||||
t.Error("Kill should collapse the deadline to ~now")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendOnceOnlyFiresOnce(t *testing.T) {
|
||||
e := &ExtendOnce{By: time.Minute}
|
||||
// Same run id: only the first call extends.
|
||||
d1 := e.OnSoftTimeout(context.Background(), run.RunInfo{RunID: "r1"}, Progress{})
|
||||
d2 := e.OnSoftTimeout(context.Background(), run.RunInfo{RunID: "r1"}, Progress{})
|
||||
if d1.ExtendBy != time.Minute {
|
||||
t.Errorf("first decision should extend, got %+v", d1)
|
||||
}
|
||||
if d2.ExtendBy != 0 || d2.Kill {
|
||||
t.Errorf("second call for the same run should be a no-op, got %+v", d2)
|
||||
}
|
||||
// A DIFFERENT run still gets its own one extension (per-run, not global).
|
||||
if d3 := e.OnSoftTimeout(context.Background(), run.RunInfo{RunID: "r2"}, Progress{}); d3.ExtendBy != time.Minute {
|
||||
t.Errorf("a different run should get its own extension, got %+v", d3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroSoftTimeoutNotMonitored(t *testing.T) {
|
||||
s := New(nil, 3)
|
||||
if h := s.Monitor(context.Background(), run.RunInfo{}, 0); h != nil {
|
||||
t.Error("zero soft timeout should return a nil handle (not monitored)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# examples/reviewer — the light-tier canary
|
||||
|
||||
A **gadfly-shaped adversarial PR reviewer built on the executus core only** — no
|
||||
batteries, no database, no host adapters. It exists to prove that the core is
|
||||
sufficient for a static-binary light host (gadfly's shape), and that such a host
|
||||
keeps a `go.sum` free of `gorm`/`redis`/`discordgo`/`sqlite`.
|
||||
|
||||
What it exercises, all from core:
|
||||
|
||||
| Concern | executus core piece |
|
||||
|---|---|
|
||||
| Env-driven model fleet + tier overrides | `config.Env` + `model.Configure` |
|
||||
| Tier resolution + failover | `model.ParseModelForContext` |
|
||||
| N models × M lenses swarm | `fanout.Run` (with `PerKey` per-provider caps) |
|
||||
| Structured findings per cell | `model.GenerateWith[T]` |
|
||||
| One report section per model, worst-verdict-led | `Consolidate` (local) |
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
REVIEWER_MODELS=fast,thinking \
|
||||
ANTHROPIC_API_KEY=sk-... \
|
||||
go run ./examples/reviewer -diff "$(git diff HEAD~1)"
|
||||
```
|
||||
|
||||
Config (all optional, `REVIEWER_`-prefixed env):
|
||||
|
||||
- `REVIEWER_MODELS` — csv of tier names / `provider/model` specs (default `fast`)
|
||||
- `REVIEWER_MODEL_TIER_<NAME>` — override a tier's resolved spec
|
||||
- `REVIEWER_MAX_CONCURRENT` — total in-flight swarm cells (default 6)
|
||||
- `REVIEWER_PROVIDER_CONCURRENCY` — per-provider cap (default 3)
|
||||
|
||||
## Test
|
||||
|
||||
`reviewer_test.go` runs the whole swarm against majordomo's fake provider
|
||||
(hermetic, no network) and asserts the consolidated verdicts. A `go list -deps`
|
||||
check in CI confirms the package pulls in no battery and no DB driver — the
|
||||
light-tier invariant.
|
||||
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/fanout"
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/model"
|
||||
)
|
||||
|
||||
// DefaultLenses is the canary's review suite (mirrors gadfly's default).
|
||||
var DefaultLenses = []Lens{
|
||||
{Name: "security", Focus: "auth, injection, secret leakage, unsafe deserialization, SSRF."},
|
||||
{Name: "correctness", Focus: "logic errors, broken invariants, off-by-one, contract violations."},
|
||||
{Name: "error-handling", Focus: "swallowed errors, missing timeouts, races, unhandled edge cases."},
|
||||
}
|
||||
|
||||
// Reviewer is configured entirely from the environment (the GADFLY_*-style light
|
||||
// host): REVIEWER_MODELS (csv of tier/spec), REVIEWER_MODEL_TIER_<NAME> overrides,
|
||||
// REVIEWER_MAX_CONCURRENT, REVIEWER_PROVIDER_CONCURRENCY. The diff is read from
|
||||
// -diff or stdin.
|
||||
//
|
||||
// REVIEWER_MODELS=fast,thinking ANTHROPIC_API_KEY=... go run ./examples/reviewer < my.diff
|
||||
func main() {
|
||||
cfg := config.Env("REVIEWER_")
|
||||
|
||||
// Tier table from env, with code defaults.
|
||||
model.Configure(cfg, map[string]string{
|
||||
"fast": "anthropic/claude-haiku-4-5",
|
||||
"thinking": "anthropic/claude-opus-4-8",
|
||||
}, 0)
|
||||
|
||||
fleet := splitCSV(cfg.String("models", "fast"))
|
||||
maxConc := cfg.Int("max_concurrent", 6)
|
||||
perProvider := cfg.Int("provider_concurrency", 3)
|
||||
|
||||
diffFlag := flag.String("diff", "", "diff text to review; reads stdin when empty")
|
||||
flag.Parse()
|
||||
diff := *diffFlag
|
||||
if strings.TrimSpace(diff) == "" {
|
||||
// Guard against blocking forever on an interactive TTY (no piped input).
|
||||
if fi, _ := os.Stdin.Stat(); fi != nil && fi.Mode()&os.ModeCharDevice != 0 {
|
||||
fmt.Fprintln(os.Stderr, "reviewer: no diff (pass -diff or pipe one on stdin)")
|
||||
os.Exit(2)
|
||||
}
|
||||
b, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "reviewer: reading stdin: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
diff = string(b)
|
||||
}
|
||||
if strings.TrimSpace(diff) == "" {
|
||||
fmt.Fprintln(os.Stderr, "reviewer: no diff (pass -diff or pipe one on stdin)")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
var models []NamedModel
|
||||
for _, spec := range fleet {
|
||||
_, m, err := model.ParseModelForContext(ctx, spec)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "reviewer: resolve model %q: %v\n", spec, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
models = append(models, NamedModel{Name: spec, Provider: providerOf(spec), Model: m})
|
||||
}
|
||||
|
||||
results := Review(ctx, models, DefaultLenses, diff, fanout.Options[cell]{
|
||||
MaxConcurrent: maxConc,
|
||||
PerKey: perKeyCaps(models, perProvider),
|
||||
})
|
||||
fmt.Print(Consolidate(results))
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// providerOf returns a model spec's provider (the first path segment, e.g.
|
||||
// "anthropic/claude-…" → "anthropic"; a bare tier name → itself).
|
||||
func providerOf(spec string) string {
|
||||
if i := strings.IndexByte(spec, '/'); i > 0 {
|
||||
return spec[:i]
|
||||
}
|
||||
return spec // bare tier name → its own bucket (don't collapse distinct tiers)
|
||||
}
|
||||
|
||||
// perKeyCaps builds the PerKey map: each distinct provider capped at perProvider.
|
||||
func perKeyCaps(models []NamedModel, perProvider int) map[string]int {
|
||||
if perProvider <= 0 {
|
||||
return nil
|
||||
}
|
||||
caps := map[string]int{}
|
||||
for _, m := range models {
|
||||
caps[m.Provider] = perProvider
|
||||
}
|
||||
return caps
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Command reviewer is executus's light-tier CANARY: a gadfly-shaped adversarial
|
||||
// PR reviewer built on the executus CORE ONLY — no batteries, no DB, no host.
|
||||
// It proves the core is sufficient for a static-binary host like gadfly:
|
||||
//
|
||||
// - config.Env → env-driven model fleet + concurrency (GADFLY_*-style)
|
||||
// - model.Configure/... → tier resolution + failover over majordomo
|
||||
// - fanout.Run → the N-models × M-lenses swarm, with per-provider caps
|
||||
// - model.GenerateWith[T] → structured findings per (model, lens)
|
||||
// - consolidation → one report section per model, worst-verdict-led
|
||||
//
|
||||
// The whole thing imports only executus core packages, so a binary built from it
|
||||
// keeps a go.sum free of gorm/redis/discordgo/sqlite — the light-tier invariant.
|
||||
//
|
||||
// See reviewer_test.go for the hermetic swarm test (majordomo's fake provider).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/fanout"
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/model"
|
||||
)
|
||||
|
||||
// Severity orders findings; the rank drives a model's worst-verdict header.
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
SevTrivial Severity = "trivial"
|
||||
SevSmall Severity = "small"
|
||||
SevMedium Severity = "medium"
|
||||
SevHigh Severity = "high"
|
||||
SevCritical Severity = "critical"
|
||||
)
|
||||
|
||||
func severityRank(s Severity) int {
|
||||
switch s {
|
||||
case SevCritical:
|
||||
return 4
|
||||
case SevHigh:
|
||||
return 3
|
||||
case SevMedium:
|
||||
return 2
|
||||
case SevSmall:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Finding is one issue a lens reports. It is the structured-output schema the
|
||||
// model must satisfy (majordomo derives the JSON schema from this struct).
|
||||
type Finding struct {
|
||||
Severity Severity `json:"severity" jsonschema:"enum=trivial,enum=small,enum=medium,enum=high,enum=critical"`
|
||||
Title string `json:"title"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// lensReport is the per-(model,lens) structured response.
|
||||
type lensReport struct {
|
||||
Findings []Finding `json:"findings"`
|
||||
}
|
||||
|
||||
// Lens is one review dimension (security / correctness / …).
|
||||
type Lens struct {
|
||||
Name string
|
||||
Focus string // appended to the base system prompt
|
||||
}
|
||||
|
||||
// NamedModel is a resolved model plus the label + provider used for fan-out
|
||||
// keying (per-provider concurrency) and reporting.
|
||||
type NamedModel struct {
|
||||
Name string // display label (the tier/spec the host configured)
|
||||
Provider string // fan-out key for PerKey concurrency (e.g. "ollama-cloud")
|
||||
Model llm.Model
|
||||
}
|
||||
|
||||
// LensResult is one swarm cell's outcome.
|
||||
type LensResult struct {
|
||||
Model string
|
||||
Lens string
|
||||
Findings []Finding
|
||||
Err error
|
||||
}
|
||||
|
||||
const baseSystemPrompt = "You are an adversarial code reviewer. Review the diff for real, verifiable problems only — no style nits. Return ONLY JSON matching the schema. Report nothing if you find nothing."
|
||||
|
||||
// Review runs every (model × lens) cell of the swarm concurrently, bounded by
|
||||
// opts (total + per-provider caps), and returns one LensResult per cell. A cell
|
||||
// whose model call fails carries the error in LensResult.Err — one bad cell
|
||||
// never aborts the swarm (the closure embeds per-cell errors in LensResult.Err).
|
||||
func Review(ctx context.Context, models []NamedModel, lenses []Lens, diff string, opts fanout.Options[cell]) []LensResult {
|
||||
cells := make([]cell, 0, len(models)*len(lenses))
|
||||
for _, m := range models {
|
||||
for _, l := range lenses {
|
||||
cells = append(cells, cell{model: m, lens: l})
|
||||
}
|
||||
}
|
||||
// Key each cell by its provider so PerKey throttles per backend (the
|
||||
// GADFLY_PROVIDER_CONCURRENCY analogue).
|
||||
if opts.Key == nil {
|
||||
opts.Key = func(c cell) string { return c.model.Provider }
|
||||
}
|
||||
results := fanout.Run(ctx, cells, opts, func(ctx context.Context, c cell) (LensResult, error) {
|
||||
sys := baseSystemPrompt
|
||||
if c.lens.Focus != "" {
|
||||
sys += "\n\nLens — " + c.lens.Name + ": " + c.lens.Focus
|
||||
}
|
||||
msgs := []llm.Message{{Role: llm.RoleUser, Parts: []llm.Part{llm.Text("Diff under review:\n" + diff)}}}
|
||||
rep, err := model.GenerateWith[lensReport](ctx, c.model.Model, sys, msgs)
|
||||
lr := LensResult{Model: c.model.Name, Lens: c.lens.Name, Findings: rep.Findings, Err: err}
|
||||
// Return the value either way (err embedded) so every cell reports.
|
||||
return lr, nil
|
||||
})
|
||||
out := make([]LensResult, 0, len(results))
|
||||
for _, r := range results {
|
||||
if r.Err != nil { // a swarm-level error (ctx cancel) with no value
|
||||
out = append(out, LensResult{Err: r.Err})
|
||||
continue
|
||||
}
|
||||
out = append(out, r.Value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cell is one (model, lens) swarm task.
|
||||
type cell struct {
|
||||
model NamedModel
|
||||
lens Lens
|
||||
}
|
||||
|
||||
// Consolidate renders the swarm's results into one report: a section per model,
|
||||
// each led by that model's worst finding severity, mirroring gadfly's
|
||||
// one-comment-per-model output.
|
||||
func Consolidate(results []LensResult) string {
|
||||
byModel := map[string][]LensResult{}
|
||||
var order []string
|
||||
aborted := 0 // cells dropped before running (swarm cancelled) — no model attribution
|
||||
for _, r := range results {
|
||||
if r.Model == "" {
|
||||
if r.Err != nil {
|
||||
aborted++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, ok := byModel[r.Model]; !ok {
|
||||
order = append(order, r.Model)
|
||||
}
|
||||
byModel[r.Model] = append(byModel[r.Model], r)
|
||||
}
|
||||
sort.Strings(order)
|
||||
|
||||
var b strings.Builder
|
||||
if aborted > 0 {
|
||||
fmt.Fprintf(&b, "> ⚠ swarm cancelled — %d cell(s) did not run; results below are partial.\n\n", aborted)
|
||||
}
|
||||
for _, m := range order {
|
||||
rs := byModel[m]
|
||||
var all []Finding
|
||||
worst := -1
|
||||
errored := 0
|
||||
for _, r := range rs {
|
||||
if r.Err != nil {
|
||||
errored++
|
||||
continue
|
||||
}
|
||||
all = append(all, r.Findings...)
|
||||
for _, f := range r.Findings {
|
||||
if severityRank(f.Severity) > worst {
|
||||
worst = severityRank(f.Severity)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A model whose every lens errored produced NO data — saying "no issues
|
||||
// found" would be misleading, so it gets its own verdict.
|
||||
successful := len(rs) - errored
|
||||
verdict := "no issues found"
|
||||
switch {
|
||||
case successful == 0 && errored > 0:
|
||||
verdict = "review incomplete"
|
||||
case worst >= severityRank(SevHigh):
|
||||
verdict = "blocking issues found"
|
||||
case worst >= 0:
|
||||
verdict = "minor issues"
|
||||
}
|
||||
fmt.Fprintf(&b, "## %s — %s", m, verdict)
|
||||
if errored > 0 {
|
||||
fmt.Fprintf(&b, " (⚠ %d lens(es) errored)", errored)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
sort.SliceStable(all, func(i, j int) bool {
|
||||
return severityRank(all[i].Severity) > severityRank(all[j].Severity)
|
||||
})
|
||||
for _, f := range all {
|
||||
fmt.Fprintf(&b, "- [%s] %s — %s\n", f.Severity, f.Title, f.Detail)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/fanout"
|
||||
)
|
||||
|
||||
// TestReviewSwarm proves the light-tier path end-to-end against the fake
|
||||
// provider: a 2-model × 3-lens swarm runs, structured findings parse, and
|
||||
// consolidation produces one verdict-led section per model — no batteries, no
|
||||
// network.
|
||||
func TestReviewSwarm(t *testing.T) {
|
||||
fp := fake.New("fakeprov")
|
||||
|
||||
// Model "hot" reports a high-severity finding on every lens; "cold" reports
|
||||
// nothing. Each model is called once per lens (3×), so enqueue 3 each.
|
||||
hot := `{"findings":[{"severity":"high","title":"SQL injection","detail":"unsanitized id in query"}]}`
|
||||
cold := `{"findings":[]}`
|
||||
for i := 0; i < 3; i++ {
|
||||
fp.Enqueue("hot", fake.Reply(hot))
|
||||
fp.Enqueue("cold", fake.Reply(cold))
|
||||
}
|
||||
hotM, err := fp.Model("hot")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
coldM, err := fp.Model("cold")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
models := []NamedModel{
|
||||
{Name: "hot", Provider: "fakeprov", Model: hotM},
|
||||
{Name: "cold", Provider: "fakeprov", Model: coldM},
|
||||
}
|
||||
lenses := []Lens{{Name: "security"}, {Name: "correctness"}, {Name: "error-handling"}}
|
||||
|
||||
results := Review(context.Background(), models, lenses, "some diff",
|
||||
fanout.Options[cell]{MaxConcurrent: 6, PerKey: map[string]int{"fakeprov": 3}})
|
||||
|
||||
// 2 models × 3 lenses = 6 cells, all successful.
|
||||
if len(results) != 6 {
|
||||
t.Fatalf("got %d cells, want 6", len(results))
|
||||
}
|
||||
var hotFindings, coldFindings, errs int
|
||||
for _, r := range results {
|
||||
if r.Err != nil {
|
||||
errs++
|
||||
continue
|
||||
}
|
||||
switch r.Model {
|
||||
case "hot":
|
||||
hotFindings += len(r.Findings)
|
||||
case "cold":
|
||||
coldFindings += len(r.Findings)
|
||||
}
|
||||
}
|
||||
if errs != 0 {
|
||||
t.Errorf("expected no cell errors, got %d", errs)
|
||||
}
|
||||
if hotFindings != 3 { // one per lens
|
||||
t.Errorf("hot model findings = %d, want 3", hotFindings)
|
||||
}
|
||||
if coldFindings != 0 {
|
||||
t.Errorf("cold model findings = %d, want 0", coldFindings)
|
||||
}
|
||||
|
||||
report := Consolidate(results)
|
||||
if !strings.Contains(report, "hot — blocking issues found") {
|
||||
t.Errorf("hot section should lead with a blocking verdict:\n%s", report)
|
||||
}
|
||||
if !strings.Contains(report, "cold — no issues found") {
|
||||
t.Errorf("cold section should report no issues:\n%s", report)
|
||||
}
|
||||
if !strings.Contains(report, "SQL injection") {
|
||||
t.Errorf("report should surface the finding:\n%s", report)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsolidateVerdicts checks the worst-severity-led header logic.
|
||||
func TestConsolidateVerdicts(t *testing.T) {
|
||||
got := Consolidate([]LensResult{
|
||||
{Model: "m", Lens: "a", Findings: []Finding{{Severity: SevSmall, Title: "x"}}},
|
||||
{Model: "m", Lens: "b", Findings: []Finding{{Severity: SevMedium, Title: "y"}}},
|
||||
})
|
||||
if !strings.Contains(got, "m — minor issues") {
|
||||
t.Errorf("medium-max should be 'minor issues', got:\n%s", got)
|
||||
}
|
||||
// An errored lens is surfaced in the header.
|
||||
got = Consolidate([]LensResult{
|
||||
{Model: "m", Lens: "a", Findings: []Finding{{Severity: SevCritical, Title: "boom"}}},
|
||||
{Model: "m", Lens: "b", Err: context.DeadlineExceeded},
|
||||
})
|
||||
if !strings.Contains(got, "blocking issues found") || !strings.Contains(got, "errored") {
|
||||
t.Errorf("critical + errored lens header wrong:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsolidateAllErrored: a model whose every lens errored must NOT be
|
||||
// labelled "no issues found" (the gadfly P5 finding).
|
||||
func TestConsolidateAllErrored(t *testing.T) {
|
||||
got := Consolidate([]LensResult{
|
||||
{Model: "m", Lens: "a", Err: context.DeadlineExceeded},
|
||||
{Model: "m", Lens: "b", Err: context.DeadlineExceeded},
|
||||
})
|
||||
if !strings.Contains(got, "m — review incomplete") {
|
||||
t.Errorf("all-errored model should be 'review incomplete', got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "no issues found") {
|
||||
t.Errorf("all-errored model must not say 'no issues found':\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsolidateSwarmCancelled: dropped (unattributed) cells surface a banner.
|
||||
func TestConsolidateSwarmCancelled(t *testing.T) {
|
||||
got := Consolidate([]LensResult{
|
||||
{Err: context.Canceled}, // dropped cell, no model
|
||||
{Model: "m", Lens: "a", Findings: []Finding{{Severity: SevSmall, Title: "x"}}},
|
||||
})
|
||||
if !strings.Contains(got, "swarm cancelled") {
|
||||
t.Errorf("dropped cells should surface a cancellation banner:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -11,30 +11,20 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.18.1 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
|
||||
cloud.google.com/go v0.116.0 // indirect
|
||||
cloud.google.com/go/auth v0.9.3 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.5.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/s2a-go v0.1.8 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
google.golang.org/genai v1.59.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
|
||||
google.golang.org/grpc v1.78.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
google.golang.org/grpc v1.66.2 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
)
|
||||
|
||||
@@ -1,84 +1,134 @@
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
|
||||
cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
|
||||
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
|
||||
cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
|
||||
cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
|
||||
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
|
||||
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
|
||||
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260626223738-1fd7109a42f3 h1:KYKIFFRsXzbbBJVDa99+Fhy0zxl9G0xV/MCrLipsLL4=
|
||||
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260626223738-1fd7109a42f3/go.mod h1:UZLveG17SmENt4sne2RSLIbioix30RZbRIQUzBAnOyY=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
||||
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genai v1.59.0 h1:xp+ydkJFW8hO0hTUaAkr8TrLM9HFP3NYAwFhPd0nDqA=
|
||||
google.golang.org/genai v1.59.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
|
||||
google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -540,6 +541,10 @@ func decodeAgentManifest(data []byte) (*Agent, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// Validate the webhook IP allow-list (CIDR or bare IP); drop + warn on
|
||||
// malformed entries so a typo can't silently widen or void the allow-list.
|
||||
allowlist := validateIPAllowlist(m.WebhookIPAllowlist, m.Name)
|
||||
|
||||
ag := &Agent{
|
||||
Name: strings.TrimSpace(m.Name),
|
||||
Description: m.Description,
|
||||
@@ -559,7 +564,7 @@ func decodeAgentManifest(data []byte) (*Agent, error) {
|
||||
LowLevelTools: m.LowLevelTools,
|
||||
PersonalizationSources: m.PersonalizationSources,
|
||||
Schedule: strings.TrimSpace(m.Schedule),
|
||||
WebhookIPAllowlist: m.WebhookIPAllowlist,
|
||||
WebhookIPAllowlist: allowlist,
|
||||
ChatbotChannelFilter: strings.TrimSpace(m.ChatbotChannelFilter),
|
||||
DefaultEmoji: m.DefaultEmoji,
|
||||
StateReactEmoji: m.StateReact,
|
||||
@@ -568,3 +573,27 @@ func decodeAgentManifest(data []byte) (*Agent, error) {
|
||||
}
|
||||
return ag, nil
|
||||
}
|
||||
|
||||
// validateIPAllowlist keeps only entries that parse as a CIDR block or a bare
|
||||
// IP; malformed entries are dropped with a warning (a typo must not silently
|
||||
// widen or void the webhook allow-list). The struct field documents "CIDR
|
||||
// strings", so this enforces it at load time.
|
||||
func validateIPAllowlist(entries []string, agent string) []string {
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
e = strings.TrimSpace(e)
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
if _, _, err := net.ParseCIDR(e); err == nil {
|
||||
out = append(out, e)
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(e); ip != nil {
|
||||
out = append(out, e)
|
||||
continue
|
||||
}
|
||||
slog.Warn("agents: dropping malformed webhook_ip_allowlist entry (not a CIDR or IP)", "agent", agent, "entry", e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package persona
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateIPAllowlist(t *testing.T) {
|
||||
in := []string{"10.0.0.0/8", " 192.168.1.5 ", "not-an-ip", "", "2001:db8::/32", "garbage/99"}
|
||||
got := validateIPAllowlist(in, "test")
|
||||
want := map[string]bool{"10.0.0.0/8": true, "192.168.1.5": true, "2001:db8::/32": true}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v, want %d valid entries", got, len(want))
|
||||
}
|
||||
for _, e := range got {
|
||||
if !want[e] {
|
||||
t.Errorf("unexpected entry kept: %q", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
)
|
||||
|
||||
// criticDeadlineCheck is how often the deadline-watch goroutine polls the
|
||||
// critic's hard deadline. Small relative to any realistic soft timeout.
|
||||
const criticDeadlineCheck = time.Second
|
||||
|
||||
// criticBinding wires a CriticHandle into a run: the executor forwards activity
|
||||
// (steps + tool starts) to it, binds the run's hard cancellation to the critic's
|
||||
// extendable deadline, and exposes the critic's Steer messages as an agent
|
||||
// RunOption. All methods are nil-safe so the executor can call them
|
||||
// unconditionally when no critic is configured.
|
||||
type criticBinding struct {
|
||||
h CriticHandle
|
||||
}
|
||||
|
||||
// startCritic begins critic monitoring for this run when one is configured and
|
||||
// the agent enables it. It launches a goroutine that cancels runCtx (via
|
||||
// cancelCause) the moment the critic's hard deadline passes — the critic may
|
||||
// extend that deadline, so a healthy-but-slow run is given room while a hung one
|
||||
// is killed. When the deadline passes because the critic KILLED the run
|
||||
// (KillCause() != nil), the cancellation cause is ErrCriticKill (→ status
|
||||
// "killed"); when the backstop simply expired, it is context.DeadlineExceeded (→
|
||||
// "timeout"). Returns (nil, no-op stop) when there is no critic. The caller MUST
|
||||
// defer the returned stop.
|
||||
func (e *Executor) startCritic(runCtx context.Context, cancelCause context.CancelCauseFunc, ra RunnableAgent, info RunInfo) (*criticBinding, func()) {
|
||||
noop := func() {}
|
||||
if e.cfg.Ports.Critic == nil || !ra.Critic.Enabled {
|
||||
return nil, noop
|
||||
}
|
||||
soft := e.cfg.Defaults.CriticSoftTimeout
|
||||
if soft <= 0 {
|
||||
soft = 90 * time.Second // defensive: withFallbacks normally guarantees >0
|
||||
}
|
||||
h := e.cfg.Ports.Critic.Monitor(runCtx, info, soft)
|
||||
if h == nil {
|
||||
return nil, noop
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
// A host CriticHandle.Deadline() that panics must not crash the process
|
||||
// (this runs on its own goroutine, so the executor's top-level recover
|
||||
// can't catch it). Log-free best-effort: just stop watching.
|
||||
defer func() { _ = recover() }()
|
||||
t := time.NewTicker(criticDeadlineCheck)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-runCtx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
// A zero deadline = no hard cap (not yet set); otherwise cancel
|
||||
// once we're at or past it, distinguishing an explicit kill from a
|
||||
// natural backstop expiry so the run gets the right status.
|
||||
if d := h.Deadline(); !d.IsZero() && !time.Now().Before(d) {
|
||||
if cause := h.KillCause(); cause != nil {
|
||||
cancelCause(fmt.Errorf("%w: %s", ErrCriticKill, cause.Error()))
|
||||
} else {
|
||||
cancelCause(context.DeadlineExceeded)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return &criticBinding{h: h}, func() {
|
||||
close(done)
|
||||
h.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *criticBinding) recordStep(iter int, resp *llm.Response) {
|
||||
if b != nil {
|
||||
b.h.RecordStep(iter, resp)
|
||||
}
|
||||
}
|
||||
|
||||
// recordToolStart forwards a tool call to the critic. NOTE: majordomo's step
|
||||
// observer only fires AFTER an iteration completes, so this currently lands
|
||||
// post-tool, not at dispatch — the activity clock is refreshed once per
|
||||
// iteration, not mid-tool. A single very long tool call (e.g. a 30-min render)
|
||||
// therefore won't refresh the clock until it returns; a host that runs such
|
||||
// tools should feed interim progress to its Critic (mort's InstallProgressBridge
|
||||
// pattern). A true pre-dispatch refresh needs a majordomo hook (follow-up).
|
||||
func (b *criticBinding) recordToolStart(name, args string) {
|
||||
if b != nil {
|
||||
b.h.RecordToolStart(name, args)
|
||||
}
|
||||
}
|
||||
|
||||
// maxStepsOption returns the agent step-ceiling Option. With no critic it's a
|
||||
// fixed WithMaxSteps(base); with a critic it's a DYNAMIC WithMaxStepsFunc that
|
||||
// polls the handle each step (so the critic can raise a long run's budget),
|
||||
// falling back to base when the handle defers (MaxSteps() <= 0).
|
||||
func (b *criticBinding) maxStepsOption(base int) agent.Option {
|
||||
if b == nil {
|
||||
return agent.WithMaxSteps(base)
|
||||
}
|
||||
return agent.WithMaxStepsFunc(func() int {
|
||||
if n := b.h.MaxSteps(); n > 0 {
|
||||
return n
|
||||
}
|
||||
return base
|
||||
})
|
||||
}
|
||||
|
||||
// drainSteer returns the critic's queued steer messages (nil-safe), so the
|
||||
// executor can merge them with the session steer mailbox into one WithSteer.
|
||||
func (b *criticBinding) drainSteer() []llm.Message {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
return b.h.Steer()
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package run_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/run"
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
type fakeCritic struct{ h *fakeCriticHandle }
|
||||
|
||||
func (c *fakeCritic) Monitor(_ context.Context, _ run.RunInfo, _ time.Duration) run.CriticHandle {
|
||||
return c.h
|
||||
}
|
||||
|
||||
type fakeCriticHandle struct {
|
||||
mu sync.Mutex
|
||||
steps, tools, stops int
|
||||
steered int
|
||||
maxSteps int // 0 => defer to the run's base MaxIterations
|
||||
killCause error // non-nil simulates a critic kill
|
||||
}
|
||||
|
||||
func (h *fakeCriticHandle) RecordStep(int, *llm.Response) { h.mu.Lock(); h.steps++; h.mu.Unlock() }
|
||||
func (h *fakeCriticHandle) KillCause() error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.killCause
|
||||
}
|
||||
func (h *fakeCriticHandle) RecordToolStart(string, string) {
|
||||
h.mu.Lock()
|
||||
h.tools++
|
||||
h.mu.Unlock()
|
||||
}
|
||||
func (h *fakeCriticHandle) Steer() []llm.Message { h.mu.Lock(); h.steered++; h.mu.Unlock(); return nil }
|
||||
func (h *fakeCriticHandle) Deadline() time.Time { return time.Time{} } // no hard deadline
|
||||
func (h *fakeCriticHandle) MaxSteps() int { h.mu.Lock(); defer h.mu.Unlock(); return h.maxSteps }
|
||||
func (h *fakeCriticHandle) Stop() { h.mu.Lock(); h.stops++; h.mu.Unlock() }
|
||||
|
||||
// TestCriticRaisesStepCeiling: a critic returning a higher MaxSteps lets the agent
|
||||
// run PAST its base MaxIterations (the dynamic step ceiling). With base=1 and no
|
||||
// critic the run would hit ErrMaxSteps after the first tool-dispatch step; the
|
||||
// critic raises it to 5 so the run completes.
|
||||
func TestCriticRaisesStepCeiling(t *testing.T) {
|
||||
h := &fakeCriticHandle{maxSteps: 5}
|
||||
fp := fake.New("fake")
|
||||
fp.Enqueue("m",
|
||||
// two tool-call steps (unknown tool → tolerated error results), then answer
|
||||
fake.ReplyWith(llm.Response{ToolCalls: []llm.ToolCall{{ID: "c1", Name: "noop", Arguments: []byte(`{}`)}}}),
|
||||
fake.ReplyWith(llm.Response{ToolCalls: []llm.ToolCall{{ID: "c2", Name: "noop", Arguments: []byte(`{}`)}}}),
|
||||
fake.Reply("done after 2 tool steps"),
|
||||
)
|
||||
m, _ := fp.Model("m")
|
||||
ex := run.New(run.Config{
|
||||
Registry: tool.NewRegistry(),
|
||||
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
|
||||
Ports: run.Ports{Critic: &fakeCritic{h: h}},
|
||||
// large soft timeout so the deadline-watch never interferes in the test
|
||||
Defaults: run.Defaults{CriticSoftTimeout: time.Hour},
|
||||
})
|
||||
res := ex.Run(context.Background(),
|
||||
run.RunnableAgent{Name: "x", ModelTier: "m", MaxIterations: 1, Critic: run.CriticConfig{Enabled: true}},
|
||||
tool.Invocation{RunID: "r"}, "go")
|
||||
if res.Err != nil {
|
||||
t.Fatalf("critic raised the ceiling to 5, run should complete past base=1: %v", res.Err)
|
||||
}
|
||||
if res.Output != "done after 2 tool steps" {
|
||||
t.Errorf("output = %q", res.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCriticWired: an agent with Critic.Enabled gets monitored — Monitor returns
|
||||
// a handle the executor feeds (RecordStep), drains (Steer), and stops.
|
||||
func TestCriticWired(t *testing.T) {
|
||||
h := &fakeCriticHandle{}
|
||||
fp := fake.New("fake")
|
||||
fp.Enqueue("m", fake.Reply("done"))
|
||||
m, _ := fp.Model("m")
|
||||
ex := run.New(run.Config{
|
||||
Registry: tool.NewRegistry(),
|
||||
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
|
||||
Ports: run.Ports{Critic: &fakeCritic{h: h}},
|
||||
})
|
||||
res := ex.Run(context.Background(),
|
||||
run.RunnableAgent{Name: "watched", ModelTier: "m", Critic: run.CriticConfig{Enabled: true}},
|
||||
tool.Invocation{RunID: "r"}, "go")
|
||||
if res.Err != nil {
|
||||
t.Fatalf("run error: %v", res.Err)
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.steps < 1 {
|
||||
t.Errorf("critic should have seen >=1 step, got %d", h.steps)
|
||||
}
|
||||
if h.steered < 1 {
|
||||
t.Errorf("critic Steer should be drained at least once, got %d", h.steered)
|
||||
}
|
||||
if h.stops != 1 {
|
||||
t.Errorf("critic Stop should be called exactly once, got %d", h.stops)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCriticDisabledNotMonitored: Critic.Enabled=false → Monitor never called.
|
||||
func TestCriticDisabledNotMonitored(t *testing.T) {
|
||||
h := &fakeCriticHandle{}
|
||||
fp := fake.New("fake")
|
||||
fp.Enqueue("m", fake.Reply("done"))
|
||||
m, _ := fp.Model("m")
|
||||
ex := run.New(run.Config{
|
||||
Registry: tool.NewRegistry(),
|
||||
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
|
||||
Ports: run.Ports{Critic: &fakeCritic{h: h}},
|
||||
})
|
||||
ex.Run(context.Background(),
|
||||
run.RunnableAgent{Name: "x", ModelTier: "m"}, // Critic.Enabled=false
|
||||
tool.Invocation{RunID: "r"}, "go")
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.stops != 0 || h.steps != 0 {
|
||||
t.Errorf("disabled critic should not be monitored: steps=%d stops=%d", h.steps, h.stops)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package run_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/deliver"
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/run"
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
type recordingDelivery struct {
|
||||
target deliver.Target
|
||||
output string
|
||||
errored error
|
||||
delivers int
|
||||
}
|
||||
|
||||
func (d *recordingDelivery) Deliver(_ context.Context, t deliver.Target, output string, _ []deliver.Artifact) (string, error) {
|
||||
d.target, d.output, d.delivers = t, output, d.delivers+1
|
||||
return "msg-1", nil
|
||||
}
|
||||
func (d *recordingDelivery) DeliverError(_ context.Context, t deliver.Target, e error) error {
|
||||
d.target, d.errored = t, e
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDeliveryWired(t *testing.T) {
|
||||
d := &recordingDelivery{}
|
||||
fp := fake.New("fake")
|
||||
fp.Enqueue("m", fake.Reply("the output"))
|
||||
m, _ := fp.Model("m")
|
||||
ex := run.New(run.Config{
|
||||
Registry: tool.NewRegistry(),
|
||||
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
|
||||
Ports: run.Ports{Delivery: d},
|
||||
})
|
||||
// With a delivery target, the executor posts the output.
|
||||
ex.Run(context.Background(),
|
||||
run.RunnableAgent{Name: "x", ModelTier: "m"},
|
||||
tool.Invocation{RunID: "r", DeliveryKind: "channel", DeliveryID: "chan-9"}, "go")
|
||||
if d.delivers != 1 || d.output != "the output" || d.target.ID != "chan-9" || d.target.Kind != "channel" {
|
||||
t.Fatalf("delivery wrong: %+v out=%q", d.target, d.output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoDeliveryWithoutTarget(t *testing.T) {
|
||||
d := &recordingDelivery{}
|
||||
fp := fake.New("fake")
|
||||
fp.Enqueue("m", fake.Reply("x"))
|
||||
m, _ := fp.Model("m")
|
||||
ex := run.New(run.Config{
|
||||
Registry: tool.NewRegistry(),
|
||||
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
|
||||
Ports: run.Ports{Delivery: d},
|
||||
})
|
||||
// No DeliveryID → executor delivers nothing (caller reads Result.Output).
|
||||
ex.Run(context.Background(),
|
||||
run.RunnableAgent{Name: "x", ModelTier: "m"},
|
||||
tool.Invocation{RunID: "r"}, "go")
|
||||
if d.delivers != 0 {
|
||||
t.Errorf("no target should mean no delivery, got %d", d.delivers)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoDeliveryOnEarlyResolveError: an error BEFORE the run starts (model
|
||||
// resolve) returns before delivery is reached — neither Deliver nor DeliverError
|
||||
// fires. (Delivery covers run OUTCOMES, not pre-run setup failures.)
|
||||
func TestNoDeliveryOnEarlyResolveError(t *testing.T) {
|
||||
d := &recordingDelivery{}
|
||||
ex := run.New(run.Config{
|
||||
Registry: tool.NewRegistry(),
|
||||
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) {
|
||||
return ctx, nil, errors.New("resolve boom")
|
||||
},
|
||||
Ports: run.Ports{Delivery: d},
|
||||
})
|
||||
ex.Run(context.Background(),
|
||||
run.RunnableAgent{Name: "x", ModelTier: "m"},
|
||||
tool.Invocation{RunID: "r", DeliveryKind: "channel", DeliveryID: "chan-9"}, "go")
|
||||
if d.delivers != 0 || d.errored != nil {
|
||||
t.Errorf("early resolve failure should neither Deliver nor DeliverError: delivers=%d errored=%v", d.delivers, d.errored)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeliverErrorOnRunFailure: an in-loop run failure (the model errors) routes
|
||||
// through DeliverError with the run error.
|
||||
func TestDeliverErrorOnRunFailure(t *testing.T) {
|
||||
d := &recordingDelivery{}
|
||||
fp := fake.New("fake")
|
||||
fp.Enqueue("m", fake.Step{Err: errors.New("model boom")}) // model errors mid-run
|
||||
m, _ := fp.Model("m")
|
||||
ex := run.New(run.Config{
|
||||
Registry: tool.NewRegistry(),
|
||||
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
|
||||
Ports: run.Ports{Delivery: d},
|
||||
})
|
||||
res := ex.Run(context.Background(),
|
||||
run.RunnableAgent{Name: "x", ModelTier: "m"},
|
||||
tool.Invocation{RunID: "r", DeliveryKind: "channel", DeliveryID: "chan-9"}, "go")
|
||||
if res.Err == nil {
|
||||
t.Fatal("expected a run error")
|
||||
}
|
||||
if d.delivers != 0 {
|
||||
t.Errorf("a failed run should not Deliver (success path), got %d", d.delivers)
|
||||
}
|
||||
if d.errored == nil || d.target.ID != "chan-9" {
|
||||
t.Errorf("a failed run with a target should DeliverError to chan-9, got errored=%v target=%+v", d.errored, d.target)
|
||||
}
|
||||
}
|
||||
+172
-22
@@ -4,12 +4,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/compact"
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/deliver"
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
@@ -27,6 +29,7 @@ type Defaults struct {
|
||||
MaxConsecutiveToolErrors int // loop guard; default 3
|
||||
MaxSameToolCallRepeats int // retry-storm guard; default 3
|
||||
CompactionThresholdRatio float64 // fraction of model context to compact at; default 0.7
|
||||
CriticSoftTimeout time.Duration // idle window before the critic wakes; default 90s
|
||||
}
|
||||
|
||||
func (d Defaults) withFallbacks() Defaults {
|
||||
@@ -48,6 +51,9 @@ func (d Defaults) withFallbacks() Defaults {
|
||||
if d.CompactionThresholdRatio <= 0 {
|
||||
d.CompactionThresholdRatio = 0.7
|
||||
}
|
||||
if d.CriticSoftTimeout <= 0 {
|
||||
d.CriticSoftTimeout = 90 * time.Second
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
@@ -96,13 +102,26 @@ type Result struct {
|
||||
Steps []tool.Step
|
||||
Usage llm.Usage
|
||||
Err error
|
||||
// PostRunResult carries artifacts produced by a SessionToolFactory's PostRun
|
||||
// hook (rendered images, files). nil when no factory was set or PostRun
|
||||
// returned nil. The host delivers these (e.g. mort's chat API / Discord).
|
||||
PostRunResult *tool.PostRunResult
|
||||
}
|
||||
|
||||
// Run executes ra with the given invocation + input and returns the Result. It
|
||||
// never propagates a panic; failures surface in Result.Err.
|
||||
func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocation, input string) Result {
|
||||
// never propagates a panic; failures surface in Result.Err (a top-level recover
|
||||
// converts any panic — including from a host Port — into a run error).
|
||||
func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocation, input string) (res Result) {
|
||||
started := time.Now()
|
||||
res := Result{RunID: inv.RunID}
|
||||
res = Result{RunID: inv.RunID}
|
||||
// Enforce the no-panic contract: a panic anywhere in the run (incl. a host
|
||||
// Critic/Audit/Palette callback on the main goroutine) becomes Result.Err
|
||||
// rather than unwinding into the caller.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
res.Err = fmt.Errorf("run.Executor: recovered panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
tier := ra.ModelTier
|
||||
if tier == "" {
|
||||
@@ -141,25 +160,33 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
|
||||
|
||||
// Audit start (optional). The recorder satisfies RunTally; stamp it on the
|
||||
// invocation so a self-status tool can read live progress.
|
||||
info := RunInfo{
|
||||
RunID: inv.RunID,
|
||||
SubjectID: ra.ID,
|
||||
Name: ra.Name,
|
||||
CallerID: inv.CallerID,
|
||||
ChannelID: inv.ChannelID,
|
||||
ParentRunID: inv.ParentRunID,
|
||||
Inputs: inv.SkillInputs,
|
||||
StartedAt: started,
|
||||
MaxIterations: maxIter,
|
||||
}
|
||||
var rec RunRecorder
|
||||
var stateAcc *RunStateAccessor
|
||||
if e.cfg.Ports.Audit != nil {
|
||||
rec = e.cfg.Ports.Audit.StartRun(ctx, RunInfo{
|
||||
RunID: inv.RunID,
|
||||
SubjectID: ra.ID,
|
||||
Name: ra.Name,
|
||||
CallerID: inv.CallerID,
|
||||
ChannelID: inv.ChannelID,
|
||||
ParentRunID: inv.ParentRunID,
|
||||
Inputs: inv.SkillInputs,
|
||||
StartedAt: started,
|
||||
})
|
||||
rec = e.cfg.Ports.Audit.StartRun(ctx, info)
|
||||
}
|
||||
if rec != nil {
|
||||
stateAcc = NewRunStateAccessor(rec, maxIter, 0, started)
|
||||
inv.RunState = stateAcc
|
||||
}
|
||||
|
||||
// Steer mailbox: lets session tools (via inv.AttachImages) feed multimodal
|
||||
// messages into the running conversation before its next step. Created BEFORE
|
||||
// the toolbox build so any tool's handler captures the live AttachImages seam.
|
||||
mailbox := &steerMailbox{}
|
||||
inv.AttachImages = (&runSession{mailbox: mailbox}).AttachImages
|
||||
|
||||
// Build the toolbox from the agent's low-level tools.
|
||||
toolbox, err := e.cfg.Registry.Build(ra.LowLevelTools, inv, tool.Visibility("private"), nil)
|
||||
if err != nil {
|
||||
@@ -168,16 +195,66 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
|
||||
return res
|
||||
}
|
||||
|
||||
// Add skill__/agent__ delegation tools from the agent's palette (nil-safe:
|
||||
// no PaletteSource or empty palette → no delegation tools).
|
||||
if err := addDelegationTools(toolbox, ra, inv, e.cfg.Ports.Palette); err != nil {
|
||||
res.Err = fmt.Errorf("build delegation tools: %w", err)
|
||||
e.finishAudit(ctx, rec, "error", res, started, res.Err)
|
||||
return res
|
||||
}
|
||||
|
||||
// Per-invocation ExtraTools + a SessionToolFactory's per-run tools, added on
|
||||
// top of the agent's palette. The factory closes over the live session (the
|
||||
// AttachImages mailbox); its PostRun hook (held for after the run) produces
|
||||
// artifacts attached to res.PostRunResult, and its Cleanup is deferred. All
|
||||
// nil-safe.
|
||||
for _, t := range inv.ExtraTools {
|
||||
if err := toolbox.Add(t); err != nil {
|
||||
res.Err = fmt.Errorf("add extra tool: %w", err)
|
||||
e.finishAudit(ctx, rec, "error", res, started, res.Err)
|
||||
return res
|
||||
}
|
||||
}
|
||||
var postRun func(ctx context.Context, transcript []llm.Message, output string, runErr error) *tool.PostRunResult
|
||||
if inv.SessionToolFactory != nil {
|
||||
st := inv.SessionToolFactory(&runSession{mailbox: mailbox})
|
||||
if st.Cleanup != nil {
|
||||
defer safeCleanup(st.Cleanup) // panic-isolated, like runPostRun
|
||||
}
|
||||
for _, t := range st.Tools {
|
||||
if err := toolbox.Add(t); err != nil {
|
||||
res.Err = fmt.Errorf("add session tool: %w", err)
|
||||
e.finishAudit(ctx, rec, "error", res, started, res.Err)
|
||||
return res
|
||||
}
|
||||
}
|
||||
postRun = st.PostRun
|
||||
}
|
||||
|
||||
// Run context: bound by MaxRuntime, detached from the caller's deadline so a
|
||||
// lane/queue wait doesn't eat the run budget (mort's V10 lesson). Caller
|
||||
// cancellation still propagates via MergeCancellation. Created BEFORE the
|
||||
// step observer so the observer forwards the merged run context (not a
|
||||
// possibly-cancelled caller ctx) to OnStep consumers.
|
||||
runCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), maxRuntime)
|
||||
defer cancel()
|
||||
// MaxRuntime stays a WithTimeout so its DeadlineExceeded propagates through the
|
||||
// child chain (→ "timeout"), preserving the run's-own-timeout vs caller-cancel
|
||||
// distinction. A NESTED cause-carrying layer lets a critic kill surface as a
|
||||
// distinct "killed" without disturbing that: only an ErrCriticKill cause is
|
||||
// consulted in statusFor; a generic run error or a caller cancel is classified
|
||||
// by the run error itself.
|
||||
timeoutCtx, cancelTimeout := context.WithTimeout(context.WithoutCancel(ctx), maxRuntime)
|
||||
defer cancelTimeout()
|
||||
runCtx, cancelCause := context.WithCancelCause(timeoutCtx)
|
||||
defer cancelCause(nil)
|
||||
runCtx, mergeCancel := MergeCancellation(runCtx, ctx)
|
||||
defer mergeCancel()
|
||||
|
||||
// Critic (optional): monitors the run for a stall, can nudge/extend/kill via
|
||||
// its host Escalator. Its hard deadline is bound to runCtx (cancel on pass).
|
||||
// nil-safe: no-op when no critic is configured or the agent doesn't enable it.
|
||||
critic, stopCritic := e.startCritic(runCtx, cancelCause, ra, info)
|
||||
defer stopCritic()
|
||||
|
||||
// Step instrumentation: accumulate Result.Steps + fire inv.OnStep, feed the
|
||||
// audit recorder, and keep the live iteration counter fresh. majordomo's
|
||||
// step observer hands us each completed iteration; we zip the model's tool
|
||||
@@ -192,6 +269,7 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
|
||||
if rec != nil {
|
||||
rec.OnStep(s.Index, s.Response)
|
||||
}
|
||||
critic.recordStep(s.Index, s.Response) // keep the critic's activity clock fresh + carry the step payload
|
||||
var calls []llm.ToolCall
|
||||
if s.Response != nil {
|
||||
calls = s.Response.ToolCalls
|
||||
@@ -202,6 +280,7 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
call, r := calls[i], s.Results[i]
|
||||
critic.recordToolStart(call.Name, string(call.Arguments))
|
||||
emitter.toolStart(runCtx, call.Name, call.Arguments)
|
||||
emitter.toolEnd(runCtx, call, r.Content, r.IsError)
|
||||
if rec != nil {
|
||||
@@ -212,7 +291,10 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
|
||||
|
||||
opts := []agent.Option{
|
||||
agent.WithToolbox(toolbox),
|
||||
agent.WithMaxSteps(maxIter),
|
||||
// Step ceiling: a fixed WithMaxSteps(maxIter) normally, but when a critic is
|
||||
// active it owns a DYNAMIC ceiling (WithMaxStepsFunc) so it can raise a
|
||||
// healthy-but-long run's budget mid-flight. Falls back to maxIter.
|
||||
critic.maxStepsOption(maxIter),
|
||||
agent.WithToolErrorLimits(e.cfg.Defaults.MaxConsecutiveToolErrors, e.cfg.Defaults.MaxSameToolCallRepeats),
|
||||
agent.WithStepObserver(stepObserver),
|
||||
}
|
||||
@@ -236,9 +318,12 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
|
||||
}
|
||||
|
||||
ag := agent.New(model, e.systemPrompt(ra), opts...)
|
||||
runRes, runErr := ag.Run(runCtx, input)
|
||||
// One WithSteer drains BOTH the session mailbox (a tool's AttachImages) and
|
||||
// the critic's nudges before each step.
|
||||
steer := func() []llm.Message { return append(mailbox.drain(), critic.drainSteer()...) }
|
||||
runRes, runErr := runAgent(runCtx, ag, input, inv.Images, agent.WithSteer(steer))
|
||||
|
||||
status := statusFor(runErr)
|
||||
status := statusFor(runCtx, runErr)
|
||||
if runRes != nil {
|
||||
res.Output = runRes.Output
|
||||
res.Usage = runRes.Usage
|
||||
@@ -246,20 +331,43 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
|
||||
res.Steps = emitter.snapshot()
|
||||
res.Err = runErr
|
||||
|
||||
// PostRun: hand the SessionToolFactory's hook the full transcript (populated
|
||||
// even on partial results) so it can produce artifacts. Best-effort +
|
||||
// panic-isolated — a PostRun failure never fails an otherwise-successful run.
|
||||
if postRun != nil {
|
||||
var transcript []llm.Message
|
||||
if runRes != nil {
|
||||
transcript = runRes.Messages
|
||||
}
|
||||
// Detach from the caller's ctx: a finished/cancelled caller must not abort
|
||||
// artifact production (the hook owns its own bounding, per its contract).
|
||||
res.PostRunResult = runPostRun(detach(ctx), postRun, transcript, res.Output, runErr)
|
||||
}
|
||||
|
||||
e.finishAudit(ctx, rec, status, res, started, runErr)
|
||||
if e.cfg.Ports.Budget != nil {
|
||||
e.cfg.Ports.Budget.Commit(detach(ctx), inv.CallerID, time.Since(started).Seconds())
|
||||
}
|
||||
e.deliver(ctx, inv, res, runErr)
|
||||
return res
|
||||
}
|
||||
|
||||
// statusFor maps a run error to a RunStats.Status, distinguishing a deadline
|
||||
// (timeout) and a cancellation (cancelled — caller cancel or shutdown) from a
|
||||
// generic error so audit consumers can tell them apart.
|
||||
func statusFor(runErr error) string {
|
||||
// statusFor maps a run error to a RunStats.Status, distinguishing a critic kill
|
||||
// (killed), a deadline (timeout), and a cancellation (cancelled — caller cancel
|
||||
// or shutdown) from a generic error so audit consumers can tell them apart. The
|
||||
// run context's cancellation cause carries the distinction (ErrCriticKill /
|
||||
// DeadlineExceeded), since ctx.Err() alone only reports Canceled.
|
||||
func statusFor(runCtx context.Context, runErr error) string {
|
||||
switch {
|
||||
case runErr == nil:
|
||||
return "ok"
|
||||
// Only the kill is recovered from the cancellation cause — a critic kill
|
||||
// surfaces as a plain Canceled run error, so without this it'd read as
|
||||
// "cancelled". Everything else is classified by the run error itself, so a
|
||||
// genuine run error is never relabeled just because the context was later
|
||||
// cancelled, and a caller cancel/deadline stays "cancelled" (not "timeout").
|
||||
case errors.Is(context.Cause(runCtx), ErrCriticKill):
|
||||
return "killed"
|
||||
case errors.Is(runErr, context.DeadlineExceeded):
|
||||
return "timeout"
|
||||
case errors.Is(runErr, context.Canceled):
|
||||
@@ -308,6 +416,23 @@ func (e *Executor) compactionThreshold(tier string) int {
|
||||
return int(float64(max) * e.cfg.Defaults.CompactionThresholdRatio)
|
||||
}
|
||||
|
||||
// deliver posts the run's output (or error) via run.Ports.Delivery when both a
|
||||
// Delivery and a target (inv.DeliveryID) are set. No target = the caller reads
|
||||
// Result.Output itself (the synchronous default). Best-effort + detached: a
|
||||
// delivery failure must not change the run's outcome.
|
||||
func (e *Executor) deliver(ctx context.Context, inv tool.Invocation, res Result, runErr error) {
|
||||
if e.cfg.Ports.Delivery == nil || inv.DeliveryID == "" {
|
||||
return
|
||||
}
|
||||
target := deliver.Target{Kind: inv.DeliveryKind, ID: inv.DeliveryID}
|
||||
dctx := detach(ctx)
|
||||
if runErr != nil {
|
||||
_ = e.cfg.Ports.Delivery.DeliverError(dctx, target, runErr)
|
||||
return
|
||||
}
|
||||
_, _ = e.cfg.Ports.Delivery.Deliver(dctx, target, res.Output, nil)
|
||||
}
|
||||
|
||||
// detach derives a bounded cleanup context off ctx, detached from its
|
||||
// cancellation, for post-run writes. The cancel is intentionally not returned;
|
||||
// CleanupContextTimeout bounds the lifetime.
|
||||
@@ -316,3 +441,28 @@ func detach(ctx context.Context) context.Context {
|
||||
_ = cancel // bounded by the timeout; nothing to cancel early
|
||||
return c
|
||||
}
|
||||
|
||||
// runAgent dispatches the majordomo agent loop. majordomo's Run takes a text-only
|
||||
// input arg, so when the invocation carries images they're folded into the first
|
||||
// user message (text + image parts) via WithHistory and Run is called with an
|
||||
// empty input — the model then sees a multimodal opening turn. The image-less path
|
||||
// passes the prompt straight through.
|
||||
//
|
||||
// The text part is omitted when input is blank (image-only run), matching
|
||||
// runSession.AttachImages so no empty TextPart is sent.
|
||||
func runAgent(ctx context.Context, ag *agent.Agent, input string, images []llm.ImagePart, opts ...agent.RunOption) (*agent.Result, error) {
|
||||
if len(images) == 0 {
|
||||
return ag.Run(ctx, input, opts...)
|
||||
}
|
||||
parts := make([]llm.Part, 0, len(images)+1)
|
||||
if strings.TrimSpace(input) != "" {
|
||||
parts = append(parts, llm.Text(input))
|
||||
}
|
||||
for _, img := range images {
|
||||
parts = append(parts, img)
|
||||
}
|
||||
// Copy opts before appending so a caller-supplied backing array is never
|
||||
// mutated/aliased (the variadic slice can have spare capacity).
|
||||
opts = append(opts[:len(opts):len(opts)], agent.WithHistory([]llm.Message{llm.UserParts(parts...)}))
|
||||
return ag.Run(ctx, "", opts...)
|
||||
}
|
||||
|
||||
+20
-7
@@ -148,20 +148,33 @@ func TestExecutorNilModelNoPanic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusFor maps run errors to RunStats.Status (gadfly F3).
|
||||
// TestStatusFor maps run errors + cancellation cause to RunStats.Status (gadfly F3).
|
||||
func TestStatusFor(t *testing.T) {
|
||||
bg := context.Background()
|
||||
// A context cancelled with the critic-kill cause: ctx.Err() is Canceled, but
|
||||
// context.Cause carries ErrCriticKill → "killed".
|
||||
killCtx, killCancel := context.WithCancelCause(context.Background())
|
||||
killCancel(fmt.Errorf("%w: hung", ErrCriticKill))
|
||||
// A context cancelled with a non-kill cause must NOT relabel a genuine run
|
||||
// error: a real error stays "error" even though the ctx was later cancelled.
|
||||
cancelledCtx, cc := context.WithCancelCause(context.Background())
|
||||
cc(context.DeadlineExceeded)
|
||||
cases := []struct {
|
||||
ctx context.Context
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{nil, "ok"},
|
||||
{context.DeadlineExceeded, "timeout"},
|
||||
{context.Canceled, "cancelled"},
|
||||
{fmt.Errorf("wrapped: %w", context.DeadlineExceeded), "timeout"},
|
||||
{errors.New("boom"), "error"},
|
||||
{bg, nil, "ok"},
|
||||
{bg, context.DeadlineExceeded, "timeout"},
|
||||
{bg, context.Canceled, "cancelled"},
|
||||
{bg, fmt.Errorf("wrapped: %w", context.DeadlineExceeded), "timeout"},
|
||||
{bg, errors.New("boom"), "error"},
|
||||
{killCtx, context.Canceled, "killed"},
|
||||
{cancelledCtx, errors.New("boom"), "error"}, // generic error not relabeled by cause
|
||||
{cancelledCtx, context.Canceled, "cancelled"}, // caller cancel stays cancelled, not timeout
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := statusFor(c.err); got != c.want {
|
||||
if got := statusFor(c.ctx, c.err); got != c.want {
|
||||
t.Errorf("statusFor(%v) = %q, want %q", c.err, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package run_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/run"
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
// TestExecutorFoldsInitialImages: when the invocation carries Images, they're
|
||||
// folded into the first user message (alongside the prompt text) instead of being
|
||||
// dropped — majordomo's Run input arg is text-only, so the executor seeds the
|
||||
// multimodal opening turn via history.
|
||||
func TestExecutorFoldsInitialImages(t *testing.T) {
|
||||
fp := fake.New("fake")
|
||||
fp.Enqueue("m", fake.Reply("saw the image"))
|
||||
m, _ := fp.Model("m")
|
||||
|
||||
img := llm.ImagePart{MIME: "image/png", Data: []byte("PNGDATA")}
|
||||
inv := tool.Invocation{RunID: "r1", Images: []llm.ImagePart{img}}
|
||||
ex := run.New(run.Config{
|
||||
Registry: tool.NewRegistry(),
|
||||
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
|
||||
})
|
||||
res := ex.Run(context.Background(), run.RunnableAgent{ModelTier: "m"}, inv, "describe this")
|
||||
if res.Err != nil {
|
||||
t.Fatalf("run error: %v", res.Err)
|
||||
}
|
||||
|
||||
calls := fp.Calls()
|
||||
if len(calls) == 0 {
|
||||
t.Fatal("no model calls recorded")
|
||||
}
|
||||
// The text + image must be CO-LOCATED in a single user message (not split
|
||||
// across two), so the model reads them as one multimodal turn.
|
||||
coLocated := false
|
||||
for _, msg := range calls[0].Request.Messages {
|
||||
sawImage, sawText := false, false
|
||||
for _, p := range msg.Parts {
|
||||
switch pp := p.(type) {
|
||||
case llm.ImagePart:
|
||||
if string(pp.Data) == "PNGDATA" {
|
||||
sawImage = true
|
||||
}
|
||||
case llm.TextPart:
|
||||
if strings.Contains(pp.Text, "describe this") {
|
||||
sawText = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if sawImage && sawText {
|
||||
coLocated = true
|
||||
}
|
||||
}
|
||||
if !coLocated {
|
||||
t.Error("image + prompt text were not folded into the SAME user message")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutorImageOnlyNoBlankText: an image-only run (blank prompt) must NOT emit
|
||||
// an empty TextPart — the message carries just the image, matching
|
||||
// runSession.AttachImages's guard.
|
||||
func TestExecutorImageOnlyNoBlankText(t *testing.T) {
|
||||
fp := fake.New("fake")
|
||||
fp.Enqueue("m", fake.Reply("saw it"))
|
||||
m, _ := fp.Model("m")
|
||||
|
||||
inv := tool.Invocation{RunID: "r3", Images: []llm.ImagePart{{MIME: "image/png", Data: []byte("IMG")}}}
|
||||
ex := run.New(run.Config{
|
||||
Registry: tool.NewRegistry(),
|
||||
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
|
||||
})
|
||||
res := ex.Run(context.Background(), run.RunnableAgent{ModelTier: "m"}, inv, " ")
|
||||
if res.Err != nil {
|
||||
t.Fatalf("run error: %v", res.Err)
|
||||
}
|
||||
for _, msg := range fp.Calls()[0].Request.Messages {
|
||||
for _, p := range msg.Parts {
|
||||
if tp, ok := p.(llm.TextPart); ok && strings.TrimSpace(tp.Text) == "" {
|
||||
t.Error("image-only run emitted a blank TextPart")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutorTextOnlyUnchanged: with no Images, the prompt flows through as the
|
||||
// text input (regression guard that the fold path didn't break the common case).
|
||||
func TestExecutorTextOnlyUnchanged(t *testing.T) {
|
||||
fp := fake.New("fake")
|
||||
fp.Enqueue("m", fake.Reply("ok"))
|
||||
m, _ := fp.Model("m")
|
||||
|
||||
ex := run.New(run.Config{
|
||||
Registry: tool.NewRegistry(),
|
||||
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
|
||||
})
|
||||
res := ex.Run(context.Background(), run.RunnableAgent{ModelTier: "m"}, tool.Invocation{RunID: "r2"}, "plain prompt")
|
||||
if res.Err != nil {
|
||||
t.Fatalf("run error: %v", res.Err)
|
||||
}
|
||||
calls := fp.Calls()
|
||||
if len(calls) == 0 {
|
||||
t.Fatal("no model calls recorded")
|
||||
}
|
||||
sawText := false
|
||||
for _, msg := range calls[0].Request.Messages {
|
||||
for _, p := range msg.Parts {
|
||||
if tp, ok := p.(llm.TextPart); ok && strings.Contains(tp.Text, "plain prompt") {
|
||||
sawText = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawText {
|
||||
t.Error("text-only prompt did not reach the model")
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
// addDelegationTools adds a delegation tool to the toolbox for each
|
||||
// SkillPalette / SubAgentPalette entry, backed by the PaletteSource:
|
||||
//
|
||||
// - skill__<name> invokes the named saved skill with structured inputs.
|
||||
// - agent__<name> invokes the named sub-agent with a prompt.
|
||||
//
|
||||
// Each delegated call runs as a CHILD of the current run (parentRunID =
|
||||
// inv.RunID), inheriting the caller + channel. No-op when palette is nil or both
|
||||
// palettes are empty — so an agent with no palette (or a host with no
|
||||
// PaletteSource) simply has no delegation tools, exactly as before.
|
||||
func addDelegationTools(box *llm.Toolbox, ra RunnableAgent, inv tool.Invocation, palette PaletteSource) error {
|
||||
if palette == nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{} // dedupe across both palettes by final tool name
|
||||
for _, name := range ra.SkillPalette {
|
||||
name := name // capture
|
||||
toolName := "skill__" + name
|
||||
if name == "" || seen[toolName] { // skip empty / duplicate palette entries
|
||||
continue
|
||||
}
|
||||
seen[toolName] = true
|
||||
t := llm.DefineTool(
|
||||
toolName,
|
||||
fmt.Sprintf("Delegate the task to the %q skill. Provide its declared inputs.", name),
|
||||
func(ctx context.Context, args skillDelegateArgs) (any, error) {
|
||||
out, _, status, err := palette.InvokeSkill(ctx, inv.CallerID, inv.ChannelID, name, args.Inputs, inv.RunID)
|
||||
if err != nil {
|
||||
return nil, delegationErr("skill", name, out, err)
|
||||
}
|
||||
return delegationResult(name, "skill", out, status), nil
|
||||
},
|
||||
)
|
||||
if err := box.Add(t); err != nil {
|
||||
return fmt.Errorf("add %s: %w", toolName, err)
|
||||
}
|
||||
}
|
||||
for _, name := range ra.SubAgentPalette {
|
||||
name := name // capture
|
||||
toolName := "agent__" + name
|
||||
if name == "" || seen[toolName] {
|
||||
continue
|
||||
}
|
||||
seen[toolName] = true
|
||||
t := llm.DefineTool(
|
||||
toolName,
|
||||
fmt.Sprintf("Delegate the task to the %q sub-agent with a natural-language prompt.", name),
|
||||
func(ctx context.Context, args agentDelegateArgs) (any, error) {
|
||||
out, _, status, err := palette.InvokeAgent(ctx, inv.CallerID, inv.ChannelID, name, args.Prompt, inv.RunID, "", "", nil, nil)
|
||||
if err != nil {
|
||||
return nil, delegationErr("agent", name, out, err)
|
||||
}
|
||||
return delegationResult(name, "agent", out, status), nil
|
||||
},
|
||||
)
|
||||
if err := box.Add(t); err != nil {
|
||||
return fmt.Errorf("add %s: %w", toolName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// delegationResult surfaces a non-ok child status to the parent agent (so it can
|
||||
// react to a timeout/cancel/budget stop) while still passing the partial output.
|
||||
func delegationResult(name, kind, out, status string) string {
|
||||
if status != "" && status != "ok" {
|
||||
header := fmt.Sprintf("[%s %q ended with status %q]", kind, name, status)
|
||||
if out == "" { // no trailing blank line when there's no body
|
||||
return header
|
||||
}
|
||||
return header + "\n" + out
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// delegationErr wraps a hard delegation failure, folding in any partial output
|
||||
// the child produced before failing (so it isn't silently lost).
|
||||
func delegationErr(kind, name, partial string, err error) error {
|
||||
if partial != "" {
|
||||
return fmt.Errorf("%s %q failed (partial output: %q): %w", kind, name, partial, err)
|
||||
}
|
||||
return fmt.Errorf("%s %q failed: %w", kind, name, err)
|
||||
}
|
||||
|
||||
type skillDelegateArgs struct {
|
||||
Inputs map[string]any `json:"inputs" description:"Inputs for the skill, matching its declared input schema."`
|
||||
}
|
||||
|
||||
type agentDelegateArgs struct {
|
||||
Prompt string `json:"prompt" description:"The task/prompt to hand the sub-agent."`
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package run_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/run"
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
// recordingPalette captures the delegation call it received.
|
||||
type recordingPalette struct {
|
||||
gotName, gotCaller, gotParent string
|
||||
gotInputs map[string]any
|
||||
}
|
||||
|
||||
func (p *recordingPalette) ResolveSkill(context.Context, string, string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (p *recordingPalette) InvokeSkill(_ context.Context, callerID, _, name string, inputs map[string]any, parentRunID string) (string, string, string, error) {
|
||||
p.gotName, p.gotCaller, p.gotParent, p.gotInputs = name, callerID, parentRunID, inputs
|
||||
return "the skill output", "child-run-1", "ok", nil
|
||||
}
|
||||
func (p *recordingPalette) ResolveAgent(context.Context, string, string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (p *recordingPalette) InvokeAgent(context.Context, string, string, string, string, string, string, string, []string, func(context.Context, string, string)) (string, string, string, error) {
|
||||
return "", "", "ok", nil
|
||||
}
|
||||
|
||||
// TestPaletteDelegation: an agent with a SkillPalette gets a skill__<name> tool;
|
||||
// the model calls it, the executor routes it through run.Ports.Palette as a
|
||||
// child of the current run, and the result flows back into the loop.
|
||||
func TestPaletteDelegation(t *testing.T) {
|
||||
pal := &recordingPalette{}
|
||||
|
||||
fp := fake.New("fake")
|
||||
fp.Enqueue("m",
|
||||
fake.ReplyWith(llm.Response{ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1",
|
||||
Name: "skill__helper",
|
||||
Arguments: json.RawMessage(`{"inputs":{"q":"hi"}}`),
|
||||
}}}),
|
||||
fake.Reply("delegated and done"),
|
||||
)
|
||||
m, err := fp.Model("m")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ex := run.New(run.Config{
|
||||
Registry: tool.NewRegistry(),
|
||||
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
|
||||
Ports: run.Ports{Palette: pal},
|
||||
})
|
||||
|
||||
res := ex.Run(context.Background(),
|
||||
run.RunnableAgent{ID: "a1", Name: "boss", ModelTier: "m", SkillPalette: []string{"helper"}},
|
||||
tool.Invocation{RunID: "parent-run", CallerID: "caller-7", ChannelID: "chan"},
|
||||
"delegate please")
|
||||
if res.Err != nil {
|
||||
t.Fatalf("run error: %v", res.Err)
|
||||
}
|
||||
if res.Output != "delegated and done" {
|
||||
t.Errorf("output = %q", res.Output)
|
||||
}
|
||||
if pal.gotName != "helper" {
|
||||
t.Errorf("InvokeSkill name = %q, want helper", pal.gotName)
|
||||
}
|
||||
if pal.gotCaller != "caller-7" {
|
||||
t.Errorf("InvokeSkill caller = %q, want caller-7", pal.gotCaller)
|
||||
}
|
||||
if pal.gotParent != "parent-run" {
|
||||
t.Errorf("InvokeSkill parentRunID = %q, want parent-run (child of the current run)", pal.gotParent)
|
||||
}
|
||||
if pal.gotInputs["q"] != "hi" {
|
||||
t.Errorf("InvokeSkill inputs = %+v, want q=hi", pal.gotInputs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoPaletteNoDelegationTools: nil PaletteSource → no delegation tools, run
|
||||
// still works (the agent just has no skill__/agent__ tools).
|
||||
func TestNoPaletteNoDelegationTools(t *testing.T) {
|
||||
fp := fake.New("fake")
|
||||
fp.Enqueue("m", fake.Reply("ok"))
|
||||
m, _ := fp.Model("m")
|
||||
ex := run.New(run.Config{
|
||||
Registry: tool.NewRegistry(),
|
||||
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
|
||||
})
|
||||
res := ex.Run(context.Background(),
|
||||
run.RunnableAgent{Name: "x", ModelTier: "m", SkillPalette: []string{"helper"}},
|
||||
tool.Invocation{RunID: "r"}, "hi")
|
||||
if res.Err != nil || res.Output != "ok" {
|
||||
t.Fatalf("nil-palette run failed: %v / %q", res.Err, res.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelegationDedupeAndEmptySkip: empty + duplicate palette names are skipped,
|
||||
// not turned into "skill__"/duplicate tools that error at box.Add (gadfly C0).
|
||||
func TestDelegationDedupeAndEmptySkip(t *testing.T) {
|
||||
pal := &recordingPalette{}
|
||||
fp := fake.New("fake")
|
||||
fp.Enqueue("m", fake.Reply("ok"))
|
||||
m, _ := fp.Model("m")
|
||||
ex := run.New(run.Config{
|
||||
Registry: tool.NewRegistry(),
|
||||
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
|
||||
Ports: run.Ports{Palette: pal},
|
||||
})
|
||||
// "" (empty) and a duplicate "helper" must not break the build.
|
||||
res := ex.Run(context.Background(),
|
||||
run.RunnableAgent{Name: "x", ModelTier: "m", SkillPalette: []string{"helper", "", "helper"}},
|
||||
tool.Invocation{RunID: "r"}, "hi")
|
||||
if res.Err != nil {
|
||||
t.Fatalf("empty/duplicate palette names should be skipped, not error: %v", res.Err)
|
||||
}
|
||||
if res.Output != "ok" {
|
||||
t.Fatalf("output = %q", res.Output)
|
||||
}
|
||||
}
|
||||
+30
-2
@@ -2,6 +2,7 @@ package run
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
@@ -9,6 +10,12 @@ import (
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/deliver"
|
||||
)
|
||||
|
||||
// ErrCriticKill is the cancellation cause the executor stamps on a run the
|
||||
// critic kills, so a critic kill surfaces as a distinct "killed" status (vs a
|
||||
// backstop "timeout" or a caller "cancelled"). A host CriticHandle signals a
|
||||
// kill via KillCause(); the executor wraps that reason with this sentinel.
|
||||
var ErrCriticKill = errors.New("run: critic killed the run")
|
||||
|
||||
// Ports are the host seams the run executor consumes. Every field is nil-safe:
|
||||
// a light host passes the zero Ports and gets a bounded, in-memory run with no
|
||||
// persistence, audit, budget, critic, delegation, or delivery — which is
|
||||
@@ -48,6 +55,9 @@ type RunInfo struct {
|
||||
ParentRunID string
|
||||
Inputs map[string]any
|
||||
StartedAt time.Time
|
||||
// MaxIterations is the run's base tool-dispatch step ceiling, so a critic can
|
||||
// raise it relative to the baseline (see CriticHandle.MaxSteps).
|
||||
MaxIterations int
|
||||
}
|
||||
|
||||
// RunStats is the terminal roll-up a recorder's Close writes. Mirrors mort's
|
||||
@@ -113,10 +123,17 @@ type Critic interface {
|
||||
}
|
||||
|
||||
// CriticHandle is the executor's live link to a run's critic.
|
||||
//
|
||||
// Concurrency: the executor calls RecordStep/RecordToolStart/Steer from the run
|
||||
// goroutine while a separate watch goroutine polls Deadline() and the run's end
|
||||
// calls Stop() — so implementations MUST be safe for concurrent use across these
|
||||
// methods (the critic battery's handle guards its state with a mutex).
|
||||
type CriticHandle interface {
|
||||
// RecordStep / RecordToolStart keep the critic's activity clock fresh so a
|
||||
// healthy-but-slow run is not mistaken for a hang.
|
||||
RecordStep(iter int)
|
||||
// healthy-but-slow run is not mistaken for a hang. RecordStep also carries the
|
||||
// completed step's model response (nil-safe) so the critic's Trace can show
|
||||
// what the agent actually produced, not just an iteration count.
|
||||
RecordStep(iter int, resp *llm.Response)
|
||||
RecordToolStart(name, args string)
|
||||
// Steer returns any messages the critic wants injected into the loop (a
|
||||
// nudge), drained before each step — matches majordomo agent.WithSteer.
|
||||
@@ -124,6 +141,17 @@ type CriticHandle interface {
|
||||
// Deadline returns the current hard-kill deadline (the critic may extend
|
||||
// it); the executor binds the run context to it. Zero = no hard deadline.
|
||||
Deadline() time.Time
|
||||
// MaxSteps returns the current tool-dispatch step ceiling, polled by the
|
||||
// executor each step (via majordomo WithMaxStepsFunc) so a critic can raise a
|
||||
// healthy-but-long run's iteration budget mid-flight. Return <= 0 to defer to
|
||||
// the run's base MaxIterations.
|
||||
MaxSteps() int
|
||||
// KillCause returns a non-nil reason iff the critic has decided to KILL this
|
||||
// run (as opposed to letting the hard-deadline backstop expire). The executor
|
||||
// reads it when the deadline passes: non-nil → cancel the run with
|
||||
// ErrCriticKill (status "killed"); nil → the backstop expired naturally
|
||||
// (status "timeout"). Hosts that never distinguish the two may return nil.
|
||||
KillCause() error
|
||||
// Stop ends monitoring when the run finishes.
|
||||
Stop()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
// runPostRun invokes a SessionToolFactory's PostRun hook with panic isolation:
|
||||
// a PostRun panic (or a slow artifact build that the hook mishandles) must not
|
||||
// fail an otherwise-successful run — artifacts are best-effort, the agent's text
|
||||
// output is the source of truth.
|
||||
func runPostRun(ctx context.Context,
|
||||
hook func(context.Context, []llm.Message, string, error) *tool.PostRunResult,
|
||||
transcript []llm.Message, output string, runErr error) (prr *tool.PostRunResult) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("run: PostRun hook panicked; no artifacts produced", "panic", r)
|
||||
prr = nil
|
||||
}
|
||||
}()
|
||||
return hook(ctx, transcript, output, runErr)
|
||||
}
|
||||
|
||||
// steerMailbox is a thread-safe queue of messages a session tool (via
|
||||
// tool.Invocation.AttachImages) wants injected into the agent loop before its
|
||||
// next step — the same WithSteer mechanism the critic uses for nudges, exposed
|
||||
// to ordinary tools so they can show the model content (e.g. a rendered
|
||||
// preview) it must SEE, not just be told about.
|
||||
type steerMailbox struct {
|
||||
mu sync.Mutex
|
||||
msgs []llm.Message
|
||||
}
|
||||
|
||||
func (m *steerMailbox) push(msg llm.Message) {
|
||||
m.mu.Lock()
|
||||
m.msgs = append(m.msgs, msg)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// drain returns and clears the queued messages (nil when empty).
|
||||
func (m *steerMailbox) drain() []llm.Message {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if len(m.msgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := m.msgs
|
||||
m.msgs = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// runSession implements tool.AgentSession over a steer mailbox: AttachImages
|
||||
// queues a user-role multimodal message the agent loop injects before its next
|
||||
// step. Replaces legacy agentkit's Agent.AttachImages — majordomo's *agent.Agent
|
||||
// is immutable mid-run, so mutation flows through the run-scoped steer mailbox.
|
||||
type runSession struct{ mailbox *steerMailbox }
|
||||
|
||||
func (s *runSession) AttachImages(text string, images ...llm.ImagePart) {
|
||||
parts := make([]llm.Part, 0, len(images)+1)
|
||||
if strings.TrimSpace(text) != "" {
|
||||
parts = append(parts, llm.Text(text))
|
||||
}
|
||||
for _, img := range images {
|
||||
parts = append(parts, img)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return
|
||||
}
|
||||
s.mailbox.push(llm.UserParts(parts...))
|
||||
}
|
||||
|
||||
// safeCleanup runs a SessionTools.Cleanup with panic isolation, so a misbehaving
|
||||
// teardown (temp-dir removal, handle close) can't clobber an otherwise-successful
|
||||
// run via the executor's top-level recover.
|
||||
func safeCleanup(fn func()) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("run: session Cleanup panicked", "panic", r)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package run_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/run"
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
// TestSessionToolFactoryPostRun: a SessionToolFactory's PostRun hook produces an
|
||||
// artifact (from the run output + transcript) that lands on Result.PostRunResult,
|
||||
// and its Cleanup is deferred.
|
||||
func TestSessionToolFactoryPostRun(t *testing.T) {
|
||||
fp := fake.New("fake")
|
||||
fp.Enqueue("m", fake.Reply("hello artifacts"))
|
||||
m, _ := fp.Model("m")
|
||||
|
||||
cleanupCalled := false
|
||||
inv := tool.Invocation{
|
||||
RunID: "r1",
|
||||
SessionToolFactory: func(_ tool.AgentSession) tool.SessionTools {
|
||||
return tool.SessionTools{
|
||||
PostRun: func(_ context.Context, transcript []llm.Message, output string, _ error) *tool.PostRunResult {
|
||||
return &tool.PostRunResult{
|
||||
Artifacts: []tool.Artifact{{Name: "out.txt", MimeType: "text/plain", Data: []byte(output)}},
|
||||
Metadata: map[string]any{"transcript_len": len(transcript)},
|
||||
}
|
||||
},
|
||||
Cleanup: func() { cleanupCalled = true },
|
||||
}
|
||||
},
|
||||
}
|
||||
ex := run.New(run.Config{
|
||||
Registry: tool.NewRegistry(),
|
||||
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
|
||||
})
|
||||
res := ex.Run(context.Background(), run.RunnableAgent{ModelTier: "m"}, inv, "go")
|
||||
if res.Err != nil {
|
||||
t.Fatalf("run error: %v", res.Err)
|
||||
}
|
||||
if res.PostRunResult == nil {
|
||||
t.Fatal("Result.PostRunResult is nil — PostRun hook not invoked / not attached")
|
||||
}
|
||||
if n := len(res.PostRunResult.Artifacts); n != 1 {
|
||||
t.Fatalf("artifacts = %d, want 1", n)
|
||||
}
|
||||
a := res.PostRunResult.Artifacts[0]
|
||||
if a.Name != "out.txt" || string(a.Data) != "hello artifacts" {
|
||||
t.Errorf("artifact = {%q, %q}", a.Name, string(a.Data))
|
||||
}
|
||||
if tl, _ := res.PostRunResult.Metadata["transcript_len"].(int); tl < 1 {
|
||||
t.Errorf("transcript not passed to PostRun (len=%d)", tl)
|
||||
}
|
||||
if !cleanupCalled {
|
||||
t.Error("Cleanup was not deferred/called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionToolFactoryAddsTool: tools the factory returns join the run's
|
||||
// toolbox and are callable by the model.
|
||||
func TestSessionToolFactoryAddsTool(t *testing.T) {
|
||||
fp := fake.New("fake")
|
||||
fp.Enqueue("m",
|
||||
fake.ReplyWith(llm.Response{ToolCalls: []llm.ToolCall{{ID: "c1", Name: "render", Arguments: []byte(`{}`)}}}),
|
||||
fake.Reply("rendered"),
|
||||
)
|
||||
m, _ := fp.Model("m")
|
||||
|
||||
toolCalled := false
|
||||
renderTool := llm.DefineTool("render", "render a preview",
|
||||
func(_ context.Context, _ struct{}) (any, error) { toolCalled = true; return "ok", nil })
|
||||
inv := tool.Invocation{
|
||||
RunID: "r2",
|
||||
SessionToolFactory: func(_ tool.AgentSession) tool.SessionTools {
|
||||
return tool.SessionTools{Tools: []llm.Tool{renderTool}}
|
||||
},
|
||||
}
|
||||
ex := run.New(run.Config{
|
||||
Registry: tool.NewRegistry(),
|
||||
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
|
||||
})
|
||||
res := ex.Run(context.Background(),
|
||||
run.RunnableAgent{ModelTier: "m", MaxIterations: 5}, inv, "go")
|
||||
if res.Err != nil {
|
||||
t.Fatalf("run error: %v", res.Err)
|
||||
}
|
||||
if !toolCalled {
|
||||
t.Error("session-factory tool was not added to the toolbox / not called")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Package schedule is the cron-runner battery: a generic ticker that, each
|
||||
// interval, asks a store for the jobs whose next-run time has passed, runs each
|
||||
// one, and stamps its next fire time. It is host-agnostic orchestration — the
|
||||
// host wires the store (skill.SkillStore.ListDueScheduled /
|
||||
// persona.Storage.ListScheduledAgents), the run (run.Executor), and the cron
|
||||
// "next fire" function (a cron library, or skill's schedule parser). The
|
||||
// battery owns no cron grammar of its own, so it never duplicates the parser.
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Due is one schedulable job: its id and its cron expression.
|
||||
type Due struct {
|
||||
ID string
|
||||
Cron string
|
||||
}
|
||||
|
||||
// Runner periodically fires due jobs. Every func field is required except Now
|
||||
// (defaults to time.Now) and Logger (defaults to slog.Default). Construct the
|
||||
// struct directly and call Loop (or Tick for a single pass / tests).
|
||||
type Runner struct {
|
||||
// Interval is how often Loop checks for due jobs. <= 0 defaults to 1m.
|
||||
Interval time.Duration
|
||||
// Due lists the jobs due at now.
|
||||
Due func(ctx context.Context, now time.Time) ([]Due, error)
|
||||
// Run executes one job by id.
|
||||
Run func(ctx context.Context, id string) error
|
||||
// Mark records that a job ran at ranAt and is next due at nextAt.
|
||||
Mark func(ctx context.Context, id string, ranAt, nextAt time.Time) error
|
||||
// Next computes a cron expression's next fire after a given time.
|
||||
Next func(cron string, after time.Time) (time.Time, error)
|
||||
|
||||
Now func() time.Time
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
func (r *Runner) now() time.Time {
|
||||
if r.Now != nil {
|
||||
return r.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (r *Runner) log() *slog.Logger {
|
||||
if r.Logger != nil {
|
||||
return r.Logger
|
||||
}
|
||||
return slog.Default()
|
||||
}
|
||||
|
||||
// Tick runs one pass: every currently-due job is run, then stamped with its
|
||||
// next fire time. A job whose Run or Next errors is logged and skipped (its
|
||||
// next-run time is left unchanged so it stays due and retries next tick) — one
|
||||
// bad job never stalls the others. Returns the error from Due (the only
|
||||
// pass-fatal step).
|
||||
func (r *Runner) Tick(ctx context.Context) error {
|
||||
if err := r.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
now := r.now()
|
||||
due, err := r.Due(ctx, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, j := range due {
|
||||
// Compute the next fire BEFORE running. A permanently-unparseable cron
|
||||
// then skips the job entirely (logged) rather than running it — an
|
||||
// unstamped job stays due, so checking Next first avoids a hot-loop of
|
||||
// real Run executions every tick.
|
||||
next, err := r.Next(j.Cron, now)
|
||||
if err != nil {
|
||||
r.log().Warn("scheduled job has an unparseable cron; skipping (not run, not rescheduled)", "job", j.ID, "cron", j.Cron, "error", err)
|
||||
continue
|
||||
}
|
||||
if err := r.Run(ctx, j.ID); err != nil {
|
||||
r.log().Warn("scheduled job failed; stays due, will retry next tick", "job", j.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
// A Mark failure leaves the job due, so it re-runs next tick — Run must
|
||||
// be idempotent (there is no atomic run+stamp across two host callbacks).
|
||||
if err := r.Mark(ctx, j.ID, now, next); err != nil {
|
||||
r.log().Warn("failed to stamp next run; job may re-execute next tick (Run must be idempotent)", "job", j.ID, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validate reports a misconfigured Runner (a required callback left nil) as a
|
||||
// clear error rather than a nil-deref panic on first tick.
|
||||
func (r *Runner) validate() error {
|
||||
if r.Due == nil || r.Run == nil || r.Mark == nil || r.Next == nil {
|
||||
return errors.New("schedule: Runner requires non-nil Due, Run, Mark, and Next")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Loop ticks every Interval until ctx is cancelled. A Tick error (the Due
|
||||
// lister failing) is logged and the loop continues — a transient store hiccup
|
||||
// shouldn't kill the scheduler — and a panic from any host callback is
|
||||
// recovered so one bad tick can't silently kill the scheduler goroutine.
|
||||
func (r *Runner) Loop(ctx context.Context) {
|
||||
interval := r.Interval
|
||||
if interval <= 0 {
|
||||
interval = time.Minute
|
||||
}
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
r.safeTick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) safeTick(ctx context.Context) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
r.log().Error("schedule tick panicked; scheduler continues", "panic", rec)
|
||||
}
|
||||
}()
|
||||
if err := r.Tick(ctx); err != nil {
|
||||
r.log().Warn("schedule tick failed", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTickRunsDueAndStampsNext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
var ran []string
|
||||
marked := map[string]time.Time{}
|
||||
|
||||
r := &Runner{
|
||||
Now: func() time.Time { return now },
|
||||
Due: func(_ context.Context, _ time.Time) ([]Due, error) {
|
||||
return []Due{{ID: "a", Cron: "hourly"}, {ID: "b", Cron: "bad"}}, nil
|
||||
},
|
||||
Run: func(_ context.Context, id string) error { ran = append(ran, id); return nil },
|
||||
Mark: func(_ context.Context, id string, _, next time.Time) error { marked[id] = next; return nil },
|
||||
Next: func(cron string, after time.Time) (time.Time, error) {
|
||||
if cron == "bad" {
|
||||
return time.Time{}, errors.New("unparseable")
|
||||
}
|
||||
return after.Add(time.Hour), nil
|
||||
},
|
||||
}
|
||||
if err := r.Tick(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Next is checked first, so the bad-cron job is skipped BEFORE Run — only
|
||||
// the parseable job runs and gets stamped (no hot-loop of a bad-cron Run).
|
||||
if len(ran) != 1 || ran[0] != "a" {
|
||||
t.Errorf("ran = %v, want only [a] (bad-cron b skipped before Run)", ran)
|
||||
}
|
||||
if marked["a"] != now.Add(time.Hour) {
|
||||
t.Errorf("a next = %v, want +1h", marked["a"])
|
||||
}
|
||||
if _, ok := marked["b"]; ok {
|
||||
t.Errorf("b should not be stamped (bad cron), got %v", marked["b"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTickRunFailureDoesNotStampOrStall(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var ran []string
|
||||
marked := map[string]bool{}
|
||||
r := &Runner{
|
||||
Due: func(_ context.Context, _ time.Time) ([]Due, error) {
|
||||
return []Due{{ID: "x", Cron: "h"}, {ID: "y", Cron: "h"}}, nil
|
||||
},
|
||||
Run: func(_ context.Context, id string) error {
|
||||
ran = append(ran, id)
|
||||
if id == "x" {
|
||||
return errors.New("boom")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Mark: func(_ context.Context, id string, _, _ time.Time) error { marked[id] = true; return nil },
|
||||
Next: func(string, time.Time) (time.Time, error) { return time.Now(), nil },
|
||||
}
|
||||
if err := r.Tick(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ran) != 2 { // y still runs despite x failing
|
||||
t.Errorf("ran = %v, want both attempted", ran)
|
||||
}
|
||||
if marked["x"] { // failed job NOT stamped -> stays due, retries
|
||||
t.Error("failed job x should not be stamped")
|
||||
}
|
||||
if !marked["y"] {
|
||||
t.Error("y should be stamped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTickDueErrorIsFatalToPass(t *testing.T) {
|
||||
r := &Runner{
|
||||
Due: func(context.Context, time.Time) ([]Due, error) { return nil, errors.New("store down") },
|
||||
Run: func(context.Context, string) error { return nil },
|
||||
Mark: func(context.Context, string, time.Time, time.Time) error { return nil },
|
||||
Next: func(string, time.Time) (time.Time, error) { return time.Now(), nil },
|
||||
}
|
||||
if err := r.Tick(context.Background()); err == nil {
|
||||
t.Error("Tick should surface the Due lister error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnparseableCronSkipsRunEntirely(t *testing.T) {
|
||||
var ran []string
|
||||
r := &Runner{
|
||||
Due: func(context.Context, time.Time) ([]Due, error) { return []Due{{ID: "z", Cron: "bad"}}, nil },
|
||||
Run: func(_ context.Context, id string) error { ran = append(ran, id); return nil },
|
||||
Mark: func(context.Context, string, time.Time, time.Time) error { return nil },
|
||||
Next: func(string, time.Time) (time.Time, error) { return time.Time{}, errors.New("bad cron") },
|
||||
}
|
||||
if err := r.Tick(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ran) != 0 {
|
||||
t.Errorf("a job with an unparseable cron must NOT be run (avoids hot-loop), ran=%v", ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsNilCallbacks(t *testing.T) {
|
||||
r := &Runner{Due: func(context.Context, time.Time) ([]Due, error) { return nil, nil }} // missing Run/Mark/Next
|
||||
if err := r.Tick(context.Background()); err == nil {
|
||||
t.Error("Tick should return a validation error for a partially-wired Runner, not panic")
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,13 @@ type Invocation struct {
|
||||
CallerID string
|
||||
ChannelID string
|
||||
GuildID string
|
||||
// DeliveryKind / DeliveryID name where the executor posts the run's output
|
||||
// via run.Ports.Delivery — a host-interpreted Target ("channel"/"dm"/
|
||||
// "thread"/...). An empty DeliveryID means the executor delivers nothing
|
||||
// and the caller reads Result.Output itself (the synchronous default; the
|
||||
// `.agent run` canary works this way).
|
||||
DeliveryKind string
|
||||
DeliveryID string
|
||||
// CallerIsAdmin is true when the caller is a mort admin (Member.Admin).
|
||||
// Populated by the executor at run dispatch via Bot.GetMember; defaults
|
||||
// to false on any lookup failure (member not found, DB error, empty
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
// Package tools — v11 cite.
|
||||
//
|
||||
// Anti-hallucination forcing function. The convention: agents call
|
||||
// cite(claim, url) for every numbered reference in their final
|
||||
// answer. The tool verifies the URL appears in the run's
|
||||
// touched-URL set (populated by web_search results +
|
||||
// read_page/read_pdf/read_video). If yes → write to
|
||||
// skill_run_sources, return {ok: true}. If no → return
|
||||
// {ok: false, reason: "url_not_in_run_history"} and DO NOT write.
|
||||
//
|
||||
// Skills authored without this discipline don't lose anything;
|
||||
// skills WITH it produce more reliable citations. The webui
|
||||
// renders the skill_run_sources rows as a Sources panel on the
|
||||
// run trace page — invisible to skills that don't use cite().
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
// citeParams is the LLM-facing param struct.
|
||||
type citeParams struct {
|
||||
Claim string `json:"claim" description:"The claim or fact you are asserting (e.g. 'Mort was published in 1987')."`
|
||||
URL string `json:"url" description:"The URL that supports the claim. MUST be a URL the agent has previously read via read_page/read_pdf/read_video or seen as a web_search result."`
|
||||
}
|
||||
|
||||
// citeResponse is the JSON envelope returned to the agent.
|
||||
//
|
||||
// On success: ok=true, the skill_run_sources row was written.
|
||||
// On failure: ok=false, reason=<one of the documented sentinels>.
|
||||
type citeResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Claim string `json:"claim,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// NewCite constructs the v11 cite tool. cs may be nil — handler
|
||||
// returns "not configured" at first call.
|
||||
//
|
||||
// The "anyone author / share-safe" permission shape matches every
|
||||
// other v11 research-class tool. Skills that adopt cite() get the
|
||||
// Sources panel automatically; skills that don't are unaffected.
|
||||
func NewCite(cs CitationStorage) tool.Tool {
|
||||
return tool.NewGatedTool[citeParams](
|
||||
"cite",
|
||||
"Record a citation: a claim + the URL that supports it. The URL MUST be one the agent has actually fetched via read_page/read_pdf/read_video or seen as a web_search result — citing a URL the agent never visited is rejected with reason 'url_not_in_run_history'. Successful citations populate the run's Sources panel.",
|
||||
tool.Permission{
|
||||
AuthoringRequirement: tool.RequirementAnyone,
|
||||
OperatesOn: tool.ScopeGlobal,
|
||||
SafeForShare: true,
|
||||
Categories: []string{"citation"},
|
||||
},
|
||||
func(ctx context.Context, inv tool.Invocation, p citeParams) (string, error) {
|
||||
if cs == nil {
|
||||
return "", fmt.Errorf("cite: citation storage not configured")
|
||||
}
|
||||
claim := strings.TrimSpace(p.Claim)
|
||||
urlStr := strings.TrimSpace(p.URL)
|
||||
if claim == "" {
|
||||
return marshalCite(citeResponse{
|
||||
OK: false,
|
||||
Reason: "claim_empty",
|
||||
}), nil
|
||||
}
|
||||
if urlStr == "" {
|
||||
return marshalCite(citeResponse{
|
||||
OK: false,
|
||||
Reason: "url_empty",
|
||||
}), nil
|
||||
}
|
||||
if inv.RunID == "" {
|
||||
// No run id → cite() can't verify history. Bail loud.
|
||||
return marshalCite(citeResponse{
|
||||
OK: false,
|
||||
Reason: "no_run_context",
|
||||
Claim: claim,
|
||||
URL: urlStr,
|
||||
}), nil
|
||||
}
|
||||
|
||||
touched, err := cs.GetTouchedURLs(ctx, inv.RunID)
|
||||
if err != nil {
|
||||
return marshalCite(citeResponse{
|
||||
OK: false,
|
||||
Reason: fmt.Sprintf("touched_lookup_failed: %v", err),
|
||||
Claim: claim,
|
||||
URL: urlStr,
|
||||
}), nil
|
||||
}
|
||||
if _, ok := touched[urlStr]; !ok {
|
||||
return marshalCite(citeResponse{
|
||||
OK: false,
|
||||
Reason: "url_not_in_run_history",
|
||||
Claim: claim,
|
||||
URL: urlStr,
|
||||
}), nil
|
||||
}
|
||||
|
||||
if err := cs.RecordCitation(ctx, inv.RunID, urlStr, claim); err != nil {
|
||||
return marshalCite(citeResponse{
|
||||
OK: false,
|
||||
Reason: fmt.Sprintf("record_failed: %v", err),
|
||||
Claim: claim,
|
||||
URL: urlStr,
|
||||
}), nil
|
||||
}
|
||||
return marshalCite(citeResponse{
|
||||
OK: true,
|
||||
Claim: claim,
|
||||
URL: urlStr,
|
||||
}), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func marshalCite(r citeResponse) string {
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"ok":false,"reason":"marshal_failed: %v"}`, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
// Package tools — v12 classify.
|
||||
//
|
||||
// Classification primitive: text + categories → labels + per-category
|
||||
// scores. Single-label mode (default) returns the top-1 category;
|
||||
// multi-label mode returns every category whose score crosses the
|
||||
// threshold.
|
||||
//
|
||||
// Why a dedicated tool (vs reusing extract_entities for one-of-N
|
||||
// classification): classification has a typed result (labels[] +
|
||||
// scores{}) that downstream agents consume programmatically. Folding
|
||||
// it into extract_entities would force every author to re-spec the
|
||||
// scoring schema.
|
||||
//
|
||||
// Score normalisation: the LLM's reply is normalised so each score
|
||||
// lands in [0, 1]. The single-label result returns scores for ALL
|
||||
// categories so the author can read the distribution; multi-label
|
||||
// returns labels[] of categories above 0.5.
|
||||
//
|
||||
// Test: classify_test.go covers single-label, multi-label, score
|
||||
// normalisation, > 20 categories rejected, unknown category in the
|
||||
// reply silently dropped.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/llmmeta"
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
// classifyMaxInputBytes is the input cap.
|
||||
const classifyMaxInputBytes = 16 * 1024
|
||||
|
||||
// classifyMaxCategories is the hard cap on category count.
|
||||
const classifyMaxCategories = 20
|
||||
|
||||
// classifyMultiLabelThreshold is the score threshold above which a
|
||||
// category appears in the labels[] array in multi-label mode.
|
||||
const classifyMultiLabelThreshold = 0.5
|
||||
|
||||
// classifyFallbackMaxPerRun is the per-run cap when ClassifyConfig is
|
||||
// nil.
|
||||
const classifyFallbackMaxPerRun = 20
|
||||
|
||||
// ClassifyConfig is the narrow per-deployment config surface.
|
||||
type ClassifyConfig interface {
|
||||
MaxPerRun(ctx context.Context) int
|
||||
}
|
||||
|
||||
// classifyArgs is the LLM-facing param struct.
|
||||
type classifyArgs struct {
|
||||
Text string `json:"text" description:"The text to classify. Required. Capped at 16KB."`
|
||||
Categories []string `json:"categories" description:"List of categories to score the text against. Required. Max 20."`
|
||||
MultiLabel bool `json:"multi_label,omitempty" description:"When true, returns every category scoring above 0.5. Default false → single-label (top-1) result."`
|
||||
}
|
||||
|
||||
type classifyResult struct {
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
Scores map[string]float64 `json:"scores,omitempty"`
|
||||
ModelUsed string `json:"model_used,omitempty"`
|
||||
RawReply string `json:"raw_reply,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
BudgetMsg string `json:"budget_message,omitempty"`
|
||||
}
|
||||
|
||||
// NewClassify constructs the classify tool.
|
||||
func NewClassify(helper *llmmeta.Helper, cfg ClassifyConfig, budget SearchBudget) tool.Tool {
|
||||
return tool.NewGatedTool[classifyArgs](
|
||||
"classify",
|
||||
"Classify text into one of N categories (or multiple via multi_label=true). Returns labels[] (top-1 by default) + scores{category: 0..1}. Counts against per-run and 7-day cost budgets.",
|
||||
tool.Permission{
|
||||
AuthoringRequirement: tool.RequirementAnyone,
|
||||
OperatesOn: tool.ScopeCaller,
|
||||
SafeForShare: true,
|
||||
Categories: []string{"llm-meta", "cost-bearing"},
|
||||
},
|
||||
func(ctx context.Context, inv tool.Invocation, args classifyArgs) (string, error) {
|
||||
if helper == nil {
|
||||
return "", fmt.Errorf("classify: not configured")
|
||||
}
|
||||
text := args.Text
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return marshalClassifyResult(classifyResult{Error: "text is empty"}), nil
|
||||
}
|
||||
if len(args.Categories) == 0 {
|
||||
return marshalClassifyResult(classifyResult{Error: "categories is empty"}), nil
|
||||
}
|
||||
if len(args.Categories) > classifyMaxCategories {
|
||||
return marshalClassifyResult(classifyResult{
|
||||
Error: fmt.Sprintf("too many categories (%d > %d)", len(args.Categories), classifyMaxCategories),
|
||||
}), nil
|
||||
}
|
||||
// Trim + dedupe categories so the LLM sees a clean
|
||||
// schema. Order is preserved for the prompt; the result
|
||||
// map is order-agnostic.
|
||||
categories := make([]string, 0, len(args.Categories))
|
||||
seen := make(map[string]bool, len(args.Categories))
|
||||
for _, c := range args.Categories {
|
||||
c = strings.TrimSpace(c)
|
||||
if c == "" || seen[c] {
|
||||
continue
|
||||
}
|
||||
seen[c] = true
|
||||
categories = append(categories, c)
|
||||
}
|
||||
if len(categories) == 0 {
|
||||
return marshalClassifyResult(classifyResult{Error: "categories has no non-empty entries"}), nil
|
||||
}
|
||||
|
||||
if len(text) > classifyMaxInputBytes {
|
||||
text = truncateUTF8(text, classifyMaxInputBytes)
|
||||
}
|
||||
|
||||
// Per-run budget gate.
|
||||
if budget == nil {
|
||||
maxPerRun := classifyFallbackMaxPerRun
|
||||
if cfg != nil {
|
||||
maxPerRun = cfg.MaxPerRun(ctx)
|
||||
}
|
||||
budget = NewInMemorySearchBudget(map[string]int{
|
||||
"classify": maxPerRun,
|
||||
})
|
||||
}
|
||||
count, max, exceeded := budget.CheckAndIncrement(ctx, inv.RunID, "classify")
|
||||
if exceeded {
|
||||
return marshalClassifyResult(classifyResult{
|
||||
Error: "classify_budget_exceeded",
|
||||
BudgetMsg: fmt.Sprintf("per-run classify budget exceeded (%d/%d). Ask an admin to raise skills.classify.max_per_run.", count, max),
|
||||
}), nil
|
||||
}
|
||||
|
||||
systemPrompt := "You classify text into a fixed set of categories. Return ONLY JSON. Score each category in [0,1] (1 = perfect fit). Sum of all scores does NOT need to be 1 — high overlap across categories is allowed."
|
||||
userPrompt := buildClassifyPrompt(text, categories, args.MultiLabel)
|
||||
|
||||
res, callErr := helper.Call(ctx, llmmeta.CallSpec{
|
||||
Tier: "fast",
|
||||
SystemPrompt: systemPrompt,
|
||||
UserPrompt: userPrompt,
|
||||
MaxOutputTokens: 2048,
|
||||
ResponseFormat: "json",
|
||||
RetryOnMalformedJSON: true,
|
||||
ToolName: "classify",
|
||||
RunID: inv.RunID,
|
||||
SkillID: inv.SkillID,
|
||||
CallerID: inv.CallerID,
|
||||
})
|
||||
if callErr != nil {
|
||||
return "", callErr
|
||||
}
|
||||
if !res.Success {
|
||||
kind := res.ErrorKind
|
||||
if kind == "" {
|
||||
kind = "llm_unavailable"
|
||||
}
|
||||
return marshalClassifyResult(classifyResult{Error: kind}), nil
|
||||
}
|
||||
if res.ErrorKind == llmmeta.ErrorKindMalformedJSON || res.Parsed == nil {
|
||||
return marshalClassifyResult(classifyResult{
|
||||
Error: "classification_failed",
|
||||
RawReply: res.Text,
|
||||
ModelUsed: res.ModelUsed,
|
||||
}), nil
|
||||
}
|
||||
|
||||
parsedMap, ok := res.Parsed.(map[string]any)
|
||||
if !ok {
|
||||
return marshalClassifyResult(classifyResult{
|
||||
Error: "classification_failed_not_object",
|
||||
RawReply: res.Text,
|
||||
ModelUsed: res.ModelUsed,
|
||||
}), nil
|
||||
}
|
||||
|
||||
scores := normaliseClassifyScores(parsedMap, categories)
|
||||
labels := selectClassifyLabels(scores, categories, args.MultiLabel)
|
||||
|
||||
return marshalClassifyResult(classifyResult{
|
||||
Labels: labels,
|
||||
Scores: scores,
|
||||
ModelUsed: res.ModelUsed,
|
||||
}), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// buildClassifyPrompt composes the user message.
|
||||
func buildClassifyPrompt(text string, categories []string, multiLabel bool) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Classify the text below.\n\nCategories:\n")
|
||||
for _, c := range categories {
|
||||
sb.WriteString("- ")
|
||||
sb.WriteString(c)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\nText:\n")
|
||||
sb.WriteString(text)
|
||||
sb.WriteString("\n\nReturn ONLY a JSON object: {\"scores\": {\"<category>\": <0..1 float>, ...}}.")
|
||||
if multiLabel {
|
||||
sb.WriteString(" The same text may score high in MULTIPLE categories — score each independently.")
|
||||
} else {
|
||||
sb.WriteString(" Score each category; the highest-scoring one will be the chosen label.")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// normaliseClassifyScores extracts the scores map from the LLM's
|
||||
// reply and clamps each value into [0, 1]. Categories absent from the
|
||||
// reply default to 0.
|
||||
//
|
||||
// Why we accept either {"scores": {...}} or {...}: some models reply
|
||||
// with the inner object directly, dropping the wrapping key. Both
|
||||
// shapes are valid as long as the keys match the requested category
|
||||
// names.
|
||||
func normaliseClassifyScores(parsed map[string]any, categories []string) map[string]float64 {
|
||||
scoresIn, ok := parsed["scores"].(map[string]any)
|
||||
if !ok {
|
||||
// Accept the bare-map shape too.
|
||||
scoresIn = parsed
|
||||
}
|
||||
out := make(map[string]float64, len(categories))
|
||||
for _, c := range categories {
|
||||
v, has := scoresIn[c]
|
||||
if !has {
|
||||
out[c] = 0
|
||||
continue
|
||||
}
|
||||
f, ok := coerceClassifyScore(v)
|
||||
if !ok {
|
||||
out[c] = 0
|
||||
continue
|
||||
}
|
||||
// Clamp into [0, 1].
|
||||
if f < 0 {
|
||||
f = 0
|
||||
}
|
||||
if f > 1 {
|
||||
f = 1
|
||||
}
|
||||
out[c] = f
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// coerceClassifyScore reads a JSON value as a float in [0, 1]. Accepts
|
||||
// floats, ints, and percent-strings ("85%" → 0.85).
|
||||
func coerceClassifyScore(raw any) (float64, bool) {
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(v)
|
||||
hasPct := strings.HasSuffix(trimmed, "%")
|
||||
s := strings.TrimSuffix(trimmed, "%")
|
||||
// strconv.ParseFloat (unlike fmt.Sscanf %f) rejects trailing garbage,
|
||||
// so "50extra" / "0.5x" are refused instead of silently parsed as 50/0.5.
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
if err == nil {
|
||||
if hasPct {
|
||||
f = f / 100.0
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// selectClassifyLabels picks the labels to surface. Single-label mode
|
||||
// returns the highest-scoring category. Multi-label returns every
|
||||
// category above the threshold (sorted by score desc for stable
|
||||
// rendering).
|
||||
func selectClassifyLabels(scores map[string]float64, categories []string, multiLabel bool) []string {
|
||||
if multiLabel {
|
||||
var labels []string
|
||||
for _, c := range categories {
|
||||
if scores[c] >= classifyMultiLabelThreshold {
|
||||
labels = append(labels, c)
|
||||
}
|
||||
}
|
||||
// Sort labels by score desc, then category-list order for ties.
|
||||
sortClassifyLabelsByScore(labels, scores)
|
||||
return labels
|
||||
}
|
||||
// Single-label: top-1.
|
||||
bestCat := ""
|
||||
bestScore := -1.0
|
||||
for _, c := range categories {
|
||||
if scores[c] > bestScore {
|
||||
bestScore = scores[c]
|
||||
bestCat = c
|
||||
}
|
||||
}
|
||||
// No category fit: an all-zero score set must not yield a false-positive
|
||||
// top-1 (the first category trivially beats the -1.0 sentinel). Returning
|
||||
// no label keeps "nothing matched" distinguishable from "category A won".
|
||||
if bestCat == "" || bestScore <= 0 {
|
||||
return nil
|
||||
}
|
||||
return []string{bestCat}
|
||||
}
|
||||
|
||||
// sortClassifyLabelsByScore sorts labels desc by score. Stable on
|
||||
// ties (preserves category-list order).
|
||||
func sortClassifyLabelsByScore(labels []string, scores map[string]float64) {
|
||||
for i := 1; i < len(labels); i++ {
|
||||
j := i
|
||||
for j > 0 && scores[labels[j]] > scores[labels[j-1]] {
|
||||
labels[j], labels[j-1] = labels[j-1], labels[j]
|
||||
j--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func marshalClassifyResult(r classifyResult) string {
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"error":"marshal_failed: %v"}`, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// create_file_url mints a public-token URL (mort.sh/files/<token>)
|
||||
// that resolves to a saved file_id. Use it for artifacts that are too
|
||||
// large for Discord (>25 MiB), need a stable link to share outside
|
||||
// Discord, or where the recipient is not in mort's auth domain.
|
||||
//
|
||||
// Why a separate tool (vs always returning a URL from file_save):
|
||||
// most files are private working state — only some need a public URL,
|
||||
// and minting one is a deliberate act. Decoupling save from
|
||||
// publication keeps the storage layer cheap (no token row per file)
|
||||
// and the audit clean (you can grep skill_file_tokens for "who
|
||||
// published what").
|
||||
//
|
||||
// Cycle-break: this tool can't import pkg/logic/skills directly
|
||||
// (pkg/logic/skills imports pkg/skilltools). The narrow interface
|
||||
// FileTokenMinter is declared here; mort.go bridges to
|
||||
// *skills.System.Storage() at wiring time.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
// FileToken is the wire-shape of the storage row that backs the
|
||||
// public /files/<token> URL. Mirrors pkg/logic/skills.FileToken
|
||||
// field-for-field; the adapter in mort.go is a struct copy.
|
||||
//
|
||||
// Why mirror (vs import skills.FileToken): same cycle constraint as
|
||||
// FileDomainMeta / KVDomainEntry — the tool layer cannot import
|
||||
// pkg/logic/skills.
|
||||
type FileToken struct {
|
||||
Token string
|
||||
FileID string
|
||||
SkillID string
|
||||
CallerID string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
MaxViews *int
|
||||
Views int
|
||||
}
|
||||
|
||||
// FileTokenMinter is the narrow interface the create_file_url tool
|
||||
// needs to persist a new token. Production wires to
|
||||
// *skills.gormStorage via a thin adapter in mort.go.
|
||||
type FileTokenMinter interface {
|
||||
SaveFileToken(ctx context.Context, t FileToken) error
|
||||
}
|
||||
|
||||
// Caps for create_file_url. Public so tests can assert against them.
|
||||
const (
|
||||
// DefaultFileURLExpiry is the default lifetime applied when the
|
||||
// caller doesn't supply expires_in_seconds.
|
||||
DefaultFileURLExpiry = 24 * time.Hour
|
||||
// MaxFileURLExpiry is the per-tool hard cap. 30 days is generous
|
||||
// enough for "share this report with someone" without becoming
|
||||
// effectively-permanent. Operators can lower via the
|
||||
// SkillFileURLConfigProvider; this is the floor below which the
|
||||
// admin gate doesn't apply.
|
||||
MaxFileURLExpiry = 30 * 24 * time.Hour
|
||||
// MaxFileURLViews is the per-tool hard cap on max_views. 1000 is
|
||||
// the largest value an LLM might plausibly set; anything beyond
|
||||
// is "unlimited" semantically and the caller should leave the
|
||||
// field absent.
|
||||
MaxFileURLViews = 1000
|
||||
)
|
||||
|
||||
type createFileURLArgs struct {
|
||||
FileID string `json:"file_id" description:"file_id previously saved by this skill (from file_save, code_exec, etc)."`
|
||||
ExpiresInSeconds int `json:"expires_in_seconds,omitempty" description:"How long the URL stays valid in seconds. Default 86400 (24h). Max 2592000 (30 days)."`
|
||||
MaxViews int `json:"max_views,omitempty" description:"Optional cap on the number of times the URL can be fetched. Max 1000. Omit (or 0) for unlimited within the lifetime."`
|
||||
}
|
||||
|
||||
type createFileURLResult struct {
|
||||
URL string `json:"url"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"` // RFC3339
|
||||
MaxViews int `json:"max_views,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// NewCreateFileURL constructs the create_file_url tool. nil minter →
|
||||
// "not configured" at execute time; nil fileStorage same. baseURL is
|
||||
// the public site (e.g. "https://mort.sh"); the path "/files/<token>"
|
||||
// is appended.
|
||||
//
|
||||
// Permission shape: anyone-authoring + caller-scope + share-safe +
|
||||
// files/discord/composition. The "publishing" act is a tool call,
|
||||
// not a save-time / share-time concern — every caller of a shared
|
||||
// skill mints into their own audit trail.
|
||||
func NewCreateFileURL(minter FileTokenMinter, fileStorage FileStorage, baseURL string) tool.Tool {
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
return tool.NewGatedTool[createFileURLArgs](
|
||||
"create_file_url",
|
||||
"Mint a public URL (mort.sh/files/<token>) for a saved file_id. Use for files too large for Discord (>25 MiB) or when a stable link is preferred over an attachment. Default expiry 24h; max 30 days. Optional view-count cap (max 1000).",
|
||||
tool.Permission{
|
||||
AuthoringRequirement: tool.RequirementAnyone,
|
||||
OperatesOn: tool.ScopeCaller,
|
||||
SafeForShare: true,
|
||||
Categories: []string{"files", "discord"},
|
||||
},
|
||||
func(ctx context.Context, inv tool.Invocation, args createFileURLArgs) (string, error) {
|
||||
if minter == nil || fileStorage == nil {
|
||||
return "", fmt.Errorf("create_file_url: not configured")
|
||||
}
|
||||
if strings.TrimSpace(args.FileID) == "" {
|
||||
return "", fmt.Errorf("create_file_url: file_id required")
|
||||
}
|
||||
|
||||
// Cross-skill rejection: the file MUST belong to the
|
||||
// calling skill. Without this, a hostile skill could mint
|
||||
// a URL for ANY file by file_id.
|
||||
meta, _, err := fileStorage.FileGet(ctx, args.FileID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrFileNotFound) {
|
||||
return "", fmt.Errorf("create_file_url: file_id %q not found", args.FileID)
|
||||
}
|
||||
return "", fmt.Errorf("create_file_url: %w", err)
|
||||
}
|
||||
grantedViaDescendant := false
|
||||
if meta.SkillID != inv.SkillID {
|
||||
if !descendantFileGrant(ctx, fileStorage, inv, meta.SkillID) {
|
||||
return "", fmt.Errorf("create_file_url: file_id %q does not belong to this skill (cross-skill refs rejected)", args.FileID)
|
||||
}
|
||||
grantedViaDescendant = true
|
||||
}
|
||||
// Scope gate — this is a PUBLICATION primitive (it mints an
|
||||
// unauthenticated link), so it must enforce the same per-user/per-run
|
||||
// scope isolation the read tools do: a same-skill caller must not be
|
||||
// able to publish a file scoped to another user/run. Skipped only for
|
||||
// the descendant-grant case (the worker's file scope is the worker's
|
||||
// run, not the caller's).
|
||||
if !grantedViaDescendant {
|
||||
if err := ValidateScope(inv, meta.Scope, inv.CallerIsAdmin); err != nil {
|
||||
return "", fmt.Errorf("create_file_url: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve expiry. Clamp the caller's seconds BEFORE the multiply so a
|
||||
// huge value can't overflow int64 nanoseconds into a negative
|
||||
// duration that slips under the max-expiry cap (minting an
|
||||
// already-expired token).
|
||||
expiry := DefaultFileURLExpiry
|
||||
if args.ExpiresInSeconds > 0 {
|
||||
maxSecs := int(MaxFileURLExpiry / time.Second)
|
||||
secs := args.ExpiresInSeconds
|
||||
if secs > maxSecs {
|
||||
secs = maxSecs
|
||||
}
|
||||
expiry = time.Duration(secs) * time.Second
|
||||
}
|
||||
if expiry > MaxFileURLExpiry {
|
||||
expiry = MaxFileURLExpiry
|
||||
}
|
||||
expiresAt := time.Now().Add(expiry)
|
||||
|
||||
// Resolve max_views.
|
||||
var maxViews *int
|
||||
if args.MaxViews > 0 {
|
||||
mv := args.MaxViews
|
||||
if mv > MaxFileURLViews {
|
||||
mv = MaxFileURLViews
|
||||
}
|
||||
maxViews = &mv
|
||||
}
|
||||
|
||||
// Mint a 32-byte random token, base64url-encoded
|
||||
// (padless). 43 chars long; the storage column is 64 so
|
||||
// there's room to grow without a migration.
|
||||
token, err := mintFileURLToken()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create_file_url: token generation: %w", err)
|
||||
}
|
||||
|
||||
// Persist.
|
||||
if err := minter.SaveFileToken(ctx, FileToken{
|
||||
Token: token,
|
||||
FileID: args.FileID,
|
||||
SkillID: inv.SkillID,
|
||||
CallerID: inv.CallerID,
|
||||
ExpiresAt: &expiresAt,
|
||||
MaxViews: maxViews,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("create_file_url: save: %w", err)
|
||||
}
|
||||
|
||||
url := baseURL + "/files/" + token
|
||||
res := createFileURLResult{
|
||||
URL: url,
|
||||
Token: token,
|
||||
ExpiresAt: expiresAt.UTC().Format(time.RFC3339),
|
||||
Note: "URL is public — anyone with the link can fetch this file until it expires or the view cap is reached.",
|
||||
}
|
||||
if maxViews != nil {
|
||||
res.MaxViews = *maxViews
|
||||
}
|
||||
b, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create_file_url: marshal: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// mintFileURLToken returns a 32-byte random token, base64url-encoded
|
||||
// without padding. ~190 bits of entropy, well above the
|
||||
// collision-resistance threshold for the 64-char storage column.
|
||||
func mintFileURLToken() (string, error) {
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// Package tools — v12 extract_entities.
|
||||
//
|
||||
// Structured-output workhorse: text + field schema → typed JSON
|
||||
// object. The author specifies which fields they want and what
|
||||
// types; the tool builds an appropriate prompt, asks for JSON, and
|
||||
// validates + coerces the response back into the requested types.
|
||||
//
|
||||
// Why a structured-output tool (vs forcing the agent to write its
|
||||
// own prompt): every agentic skill that needs to "pull X, Y, Z out
|
||||
// of unstructured text" otherwise re-invents the same prompt-
|
||||
// engineering pattern. extract_entities centralises it so authors
|
||||
// just describe the schema.
|
||||
//
|
||||
// Type coercion: an LLM responding with "42" when an int field was
|
||||
// requested is normal noise. The tool coerces strings to
|
||||
// int/float/bool when possible; coercion failures land the field in
|
||||
// missing_fields rather than the entities map.
|
||||
//
|
||||
// Test: extract_entities_test.go covers happy path, missing optional
|
||||
// field, missing required field surfaces in missing_fields, malformed
|
||||
// JSON retry, second-attempt failure, type coercion (string→int,
|
||||
// string→bool), unknown field type rejected at args validation.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/llmmeta"
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
// extractEntitiesMaxInputBytes is the hard input cap.
|
||||
const extractEntitiesMaxInputBytes = 32 * 1024
|
||||
|
||||
// extractEntitiesFallbackMaxPerRun is the per-run cap when
|
||||
// ExtractEntitiesConfig is nil.
|
||||
const extractEntitiesFallbackMaxPerRun = 10
|
||||
|
||||
// ExtractEntitiesConfig is the narrow per-deployment config surface
|
||||
// extract_entities reads at execute time.
|
||||
type ExtractEntitiesConfig interface {
|
||||
MaxPerRun(ctx context.Context) int
|
||||
}
|
||||
|
||||
// extractField is one row in the schema the agent supplies. The four
|
||||
// supported types match the JSON-shape primitives we can validate +
|
||||
// coerce reliably.
|
||||
//
|
||||
// Why an enum-shaped Type field (vs free-form): we need to know how
|
||||
// to validate the LLM's reply. Free-form ("integer", "Number",
|
||||
// "boolean") would invite typos that silently miss the validation.
|
||||
type extractField struct {
|
||||
Name string `json:"name" description:"Field name to populate (e.g. 'author', 'year_published'). Becomes a key in the returned entities object."`
|
||||
Description string `json:"description" description:"Short description of what to extract (e.g. 'the book author', 'the year the article was published'). Helps the model find the right value."`
|
||||
Type string `json:"type" description:"One of: 'string', 'int', 'float', 'bool', 'list_of_strings'. Determines how the LLM's reply is validated and coerced."`
|
||||
Required bool `json:"required,omitempty" description:"When true, a missing/uncoercible value lands in missing_fields rather than skipping silently."`
|
||||
}
|
||||
|
||||
// extractEntitiesArgs is the LLM-facing param struct.
|
||||
type extractEntitiesArgs struct {
|
||||
Text string `json:"text" description:"The text to extract from. Required. Capped at 32KB."`
|
||||
Fields []extractField `json:"fields" description:"Schema describing what to extract. Each field has name, description, type, and optional required flag."`
|
||||
}
|
||||
|
||||
type extractEntitiesResult struct {
|
||||
Entities map[string]any `json:"entities,omitempty"`
|
||||
MissingFields []string `json:"missing_fields,omitempty"`
|
||||
ModelUsed string `json:"model_used,omitempty"`
|
||||
RawReply string `json:"raw_reply,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
BudgetMsg string `json:"budget_message,omitempty"`
|
||||
}
|
||||
|
||||
// validExtractTypes is the closed set of Type strings the tool
|
||||
// accepts. Anything else is rejected at args validation.
|
||||
var validExtractTypes = map[string]bool{
|
||||
"string": true,
|
||||
"int": true,
|
||||
"float": true,
|
||||
"bool": true,
|
||||
"list_of_strings": true,
|
||||
}
|
||||
|
||||
// NewExtractEntities constructs the extract_entities tool.
|
||||
func NewExtractEntities(helper *llmmeta.Helper, cfg ExtractEntitiesConfig, budget SearchBudget) tool.Tool {
|
||||
return tool.NewGatedTool[extractEntitiesArgs](
|
||||
"extract_entities",
|
||||
"Extract structured fields from unstructured text via a fast LLM. Caller supplies a schema (each field has name + description + type + required); tool returns an entities object with values matching the requested types. Types: string, int, float, bool, list_of_strings. Counts against per-run and 7-day cost budgets.",
|
||||
tool.Permission{
|
||||
AuthoringRequirement: tool.RequirementAnyone,
|
||||
OperatesOn: tool.ScopeCaller,
|
||||
SafeForShare: true,
|
||||
Categories: []string{"llm-meta", "cost-bearing"},
|
||||
},
|
||||
func(ctx context.Context, inv tool.Invocation, args extractEntitiesArgs) (string, error) {
|
||||
if helper == nil {
|
||||
return "", fmt.Errorf("extract_entities: not configured")
|
||||
}
|
||||
text := args.Text
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return marshalExtractEntities(extractEntitiesResult{Error: "text is empty"}), nil
|
||||
}
|
||||
if len(args.Fields) == 0 {
|
||||
return marshalExtractEntities(extractEntitiesResult{Error: "fields is empty"}), nil
|
||||
}
|
||||
// Validate each field's Type before paying for an LLM
|
||||
// call.
|
||||
for _, f := range args.Fields {
|
||||
if strings.TrimSpace(f.Name) == "" {
|
||||
return marshalExtractEntities(extractEntitiesResult{Error: "field with empty name"}), nil
|
||||
}
|
||||
if !validExtractTypes[strings.ToLower(strings.TrimSpace(f.Type))] {
|
||||
return marshalExtractEntities(extractEntitiesResult{
|
||||
Error: fmt.Sprintf("field %q has unsupported type %q (allowed: string|int|float|bool|list_of_strings)", f.Name, f.Type),
|
||||
}), nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(text) > extractEntitiesMaxInputBytes {
|
||||
text = truncateUTF8(text, extractEntitiesMaxInputBytes)
|
||||
}
|
||||
|
||||
// Per-run budget gate.
|
||||
if budget == nil {
|
||||
maxPerRun := extractEntitiesFallbackMaxPerRun
|
||||
if cfg != nil {
|
||||
maxPerRun = cfg.MaxPerRun(ctx)
|
||||
}
|
||||
budget = NewInMemorySearchBudget(map[string]int{
|
||||
"extract_entities": maxPerRun,
|
||||
})
|
||||
}
|
||||
count, max, exceeded := budget.CheckAndIncrement(ctx, inv.RunID, "extract_entities")
|
||||
if exceeded {
|
||||
return marshalExtractEntities(extractEntitiesResult{
|
||||
Error: "extract_entities_budget_exceeded",
|
||||
BudgetMsg: fmt.Sprintf("per-run extract_entities budget exceeded (%d/%d). Ask an admin to raise skills.extract_entities.max_per_run.", count, max),
|
||||
}), nil
|
||||
}
|
||||
|
||||
systemPrompt := "You extract structured data from unstructured text. Return ONLY valid JSON with the requested keys. If a value is not present in the text, omit the key. Do NOT invent values."
|
||||
userPrompt := buildExtractPrompt(text, args.Fields)
|
||||
|
||||
res, callErr := helper.Call(ctx, llmmeta.CallSpec{
|
||||
Tier: "fast",
|
||||
SystemPrompt: systemPrompt,
|
||||
UserPrompt: userPrompt,
|
||||
MaxOutputTokens: 4096,
|
||||
ResponseFormat: "json",
|
||||
RetryOnMalformedJSON: true,
|
||||
ToolName: "extract_entities",
|
||||
RunID: inv.RunID,
|
||||
SkillID: inv.SkillID,
|
||||
CallerID: inv.CallerID,
|
||||
})
|
||||
if callErr != nil {
|
||||
return "", callErr
|
||||
}
|
||||
if !res.Success {
|
||||
kind := res.ErrorKind
|
||||
if kind == "" {
|
||||
kind = "llm_unavailable"
|
||||
}
|
||||
return marshalExtractEntities(extractEntitiesResult{Error: kind}), nil
|
||||
}
|
||||
|
||||
// Second-failure malformed JSON (success=true but parsed
|
||||
// is nil and ErrorKind=malformed_json). Surface the raw
|
||||
// reply so the agent can salvage.
|
||||
if res.ErrorKind == llmmeta.ErrorKindMalformedJSON || res.Parsed == nil {
|
||||
return marshalExtractEntities(extractEntitiesResult{
|
||||
Error: "extraction_failed",
|
||||
RawReply: res.Text,
|
||||
ModelUsed: res.ModelUsed,
|
||||
}), nil
|
||||
}
|
||||
|
||||
parsedMap, ok := res.Parsed.(map[string]any)
|
||||
if !ok {
|
||||
return marshalExtractEntities(extractEntitiesResult{
|
||||
Error: "extraction_failed_not_object",
|
||||
RawReply: res.Text,
|
||||
ModelUsed: res.ModelUsed,
|
||||
}), nil
|
||||
}
|
||||
|
||||
entities, missing := coerceExtractedEntities(parsedMap, args.Fields)
|
||||
return marshalExtractEntities(extractEntitiesResult{
|
||||
Entities: entities,
|
||||
MissingFields: missing,
|
||||
ModelUsed: res.ModelUsed,
|
||||
}), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// buildExtractPrompt composes the user message describing the schema
|
||||
// + source text.
|
||||
func buildExtractPrompt(text string, fields []extractField) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Extract the following fields from the text below. Return a JSON object with the field names as keys.\n\nFields:\n")
|
||||
for _, f := range fields {
|
||||
fmt.Fprintf(&sb, "- %s (%s): %s", f.Name, f.Type, f.Description)
|
||||
if f.Required {
|
||||
sb.WriteString(" [required]")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\nText:\n")
|
||||
sb.WriteString(text)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// coerceExtractedEntities walks the LLM's response, validating + (when
|
||||
// possible) coercing each value to the requested type. Required fields
|
||||
// missing or uncoercible land in missing[]; optional fields silently
|
||||
// drop.
|
||||
func coerceExtractedEntities(parsed map[string]any, fields []extractField) (map[string]any, []string) {
|
||||
entities := make(map[string]any, len(fields))
|
||||
var missing []string
|
||||
for _, f := range fields {
|
||||
raw, present := parsed[f.Name]
|
||||
if !present || raw == nil {
|
||||
if f.Required {
|
||||
missing = append(missing, f.Name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
value, ok := coerceFieldValue(raw, f.Type)
|
||||
if !ok {
|
||||
if f.Required {
|
||||
missing = append(missing, f.Name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
entities[f.Name] = value
|
||||
}
|
||||
return entities, missing
|
||||
}
|
||||
|
||||
// coerceFieldValue attempts to convert raw to the requested type.
|
||||
// Returns (value, true) on success or (nil, false) on failure.
|
||||
//
|
||||
// Why coerce (vs strict reject): LLMs frequently reply with strings
|
||||
// that contain numbers ("42") or pseudo-booleans ("yes"). Strict
|
||||
// rejection would force every author to clean the response themselves.
|
||||
// Coercion is conservative — string "42" → int 42 succeeds; string
|
||||
// "forty-two" → int 42 fails (the agent never asked for word-form
|
||||
// parsing).
|
||||
func coerceFieldValue(raw any, fieldType string) (any, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(fieldType)) {
|
||||
case "string":
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
return v, true
|
||||
case float64:
|
||||
return strconv.FormatFloat(v, 'f', -1, 64), true
|
||||
case bool:
|
||||
return strconv.FormatBool(v), true
|
||||
}
|
||||
return nil, false
|
||||
|
||||
case "int":
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
// JSON numbers are float64 by default.
|
||||
if v == float64(int64(v)) {
|
||||
return int64(v), true
|
||||
}
|
||||
return nil, false
|
||||
case string:
|
||||
if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil {
|
||||
return n, true
|
||||
}
|
||||
// Try float-string-with-zero-fractional ("42.0").
|
||||
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil && f == float64(int64(f)) {
|
||||
return int64(f), true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
|
||||
case "float":
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case string:
|
||||
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
|
||||
case "bool":
|
||||
switch v := raw.(type) {
|
||||
case bool:
|
||||
return v, true
|
||||
case string:
|
||||
s := strings.ToLower(strings.TrimSpace(v))
|
||||
switch s {
|
||||
case "true", "yes", "1", "y":
|
||||
return true, true
|
||||
case "false", "no", "0", "n":
|
||||
return false, true
|
||||
}
|
||||
case float64:
|
||||
return v != 0, true
|
||||
}
|
||||
return nil, false
|
||||
|
||||
case "list_of_strings":
|
||||
switch v := raw.(type) {
|
||||
case []any:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, e := range v {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
} else {
|
||||
// Mixed-type lists fail the type contract.
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
case string:
|
||||
// Single-string can be lifted into a one-element list.
|
||||
return []string{v}, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func marshalExtractEntities(r extractEntitiesResult) string {
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"error":"marshal_failed: %v"}`, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// file_delete removes a saved file by its file_id. Decrements the
|
||||
// underlying blob's refcount in storage; the blob row is removed when
|
||||
// refcount hits zero.
|
||||
//
|
||||
// Why scope is checked POST-fetch (mirrors file_get): file_id is the
|
||||
// only key the caller has; we must read the row to know the scope.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
type fileDeleteArgs struct {
|
||||
FileID string `json:"file_id" description:"Opaque file ID returned by file_save or file_list."`
|
||||
}
|
||||
|
||||
// NewFileDelete constructs the file_delete tool. storage nil → "not
|
||||
// configured" at execute time.
|
||||
func NewFileDelete(storage FileStorage) tool.Tool {
|
||||
return tool.NewGatedTool[fileDeleteArgs](
|
||||
"file_delete",
|
||||
"Remove a saved file by file_id. Returns 'ok' on success or 'not_found' if no file matched.",
|
||||
tool.Permission{
|
||||
AuthoringRequirement: tool.RequirementAnyone,
|
||||
OperatesOn: tool.ScopeCaller,
|
||||
SafeForShare: true,
|
||||
Categories: []string{"storage", "write"},
|
||||
},
|
||||
func(ctx context.Context, inv tool.Invocation, args fileDeleteArgs) (string, error) {
|
||||
if storage == nil {
|
||||
return "", fmt.Errorf("file_delete: not configured")
|
||||
}
|
||||
if args.FileID == "" {
|
||||
return "", fmt.Errorf("file_delete: file_id required")
|
||||
}
|
||||
|
||||
// Fetch first so we can validate scope before deleting. The
|
||||
// extra read is acceptable for a write path that's not in
|
||||
// the hot loop, and it preserves the cross-skill /
|
||||
// cross-user safety story.
|
||||
meta, _, err := storage.FileGet(ctx, args.FileID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrFileNotFound) {
|
||||
return "not_found", nil
|
||||
}
|
||||
return "", fmt.Errorf("file_delete: %w", err)
|
||||
}
|
||||
// Honor the descendant grant like the read tools do, so a parent
|
||||
// orchestrator can clean up a worker's artifacts (gadfly flagged the
|
||||
// asymmetry: delete previously rejected cross-skill outright).
|
||||
grantedViaDescendant := false
|
||||
if meta.SkillID != inv.SkillID {
|
||||
if !descendantFileGrant(ctx, storage, inv, meta.SkillID) {
|
||||
return "", fmt.Errorf("file_delete: file does not belong to this skill")
|
||||
}
|
||||
grantedViaDescendant = true
|
||||
}
|
||||
if !grantedViaDescendant {
|
||||
if err := ValidateScope(inv, meta.Scope, inv.CallerIsAdmin); err != nil {
|
||||
return "", fmt.Errorf("file_delete: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := storage.FileDelete(ctx, args.FileID); err != nil {
|
||||
if errors.Is(err, ErrFileNotFound) {
|
||||
// Race: row was deleted between FileGet and
|
||||
// FileDelete. Surface as a clean miss.
|
||||
return "not_found", nil
|
||||
}
|
||||
return "", fmt.Errorf("file_delete: %w", err)
|
||||
}
|
||||
return "ok", nil
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// file_descendant_grant.go — the cross-skill file-access escape hatch
|
||||
// for parent → spawned-worker handoff.
|
||||
//
|
||||
// The blanket rule everywhere in this package is "a file belongs to
|
||||
// the skill that saved it; cross-skill refs are rejected". That rule
|
||||
// breaks the agent_spawn flow: a worker saves a chart with file_save
|
||||
// under ITS ephemeral ID, returns the file_id as text, and the parent
|
||||
// (which orchestrated the whole thing) can't attach, read, or host it.
|
||||
// Observed live on the second spawn test — the chart never reached
|
||||
// Discord; general could only apologise with the file_id.
|
||||
//
|
||||
// The grant: a caller may access a file whose owning skill/agent
|
||||
// PRODUCED A RUN THAT DESCENDS FROM THE CALLER'S CURRENT RUN. In other
|
||||
// words: you may touch the artifacts of workers you (transitively)
|
||||
// dispatched in this very tree — output you were already entitled to
|
||||
// see as their tool results. You may NOT touch files from siblings,
|
||||
// ancestors, other trees, or unrelated skills; those still reject.
|
||||
//
|
||||
// Why an optional interface upgrade (vs a new constructor dep on
|
||||
// every file tool): six tools enforce the ownership rule, each with
|
||||
// its own narrow storage interface — threading a new dep through all
|
||||
// of them churns every signature and test fake. Instead, the
|
||||
// production storage adapter (mort.go's skillsFileStorageAdapter,
|
||||
// which backs ALL of those interfaces) additionally implements
|
||||
// DescendantRunChecker; the tools type-assert at the rejection site.
|
||||
// Fakes that don't implement it keep the strict behaviour — the grant
|
||||
// is fail-closed everywhere. Same pattern as KVHistoryRecorder (v7).
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
// DescendantRunChecker reports whether ownerSkillID (the file's owning
|
||||
// skill or agent ID — e.g. a spawned worker's "eph-…" ID) produced a
|
||||
// run that is a DESCENDANT of callerRunID. Production walks the audit
|
||||
// parent_run_id chain; see mort_skills_storage_adapters.go.
|
||||
type DescendantRunChecker interface {
|
||||
IsDescendantProducer(ctx context.Context, ownerSkillID, callerRunID string) (bool, error)
|
||||
}
|
||||
|
||||
// descendantFileGrant is called at a cross-skill rejection site with
|
||||
// the tool's storage dep. Returns true only when the dep implements
|
||||
// DescendantRunChecker AND the owner's run descends from the caller's
|
||||
// run. Any error or missing context keeps the strict rejection.
|
||||
func descendantFileGrant(ctx context.Context, dep any, inv tool.Invocation, ownerSkillID string) bool {
|
||||
if ownerSkillID == "" || inv.RunID == "" {
|
||||
return false
|
||||
}
|
||||
checker, ok := dep.(DescendantRunChecker)
|
||||
if !ok || checker == nil {
|
||||
return false
|
||||
}
|
||||
granted, err := checker.IsDescendantProducer(ctx, ownerSkillID, inv.RunID)
|
||||
return err == nil && granted
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// file_get fetches a previously-saved file by its opaque file_id and
|
||||
// returns the metadata + base64-encoded bytes.
|
||||
//
|
||||
// Why scope is checked POST-fetch: file_id is the only key the caller
|
||||
// knows; the scope (and therefore the authorisation envelope) is
|
||||
// stored on the FileMeta row. We must read the row first to know which
|
||||
// scope to validate. The trade-off is that file_id existence is
|
||||
// observable (a foreign caller can probe IDs and learn that one
|
||||
// exists), but the bytes themselves are still gated. file_id is a UUID,
|
||||
// so the probe surface is impractical.
|
||||
//
|
||||
// Why base64 in the response: same reason as file_save — JSON can't
|
||||
// carry arbitrary bytes natively. Callers that want a paste link or a
|
||||
// direct download go through a separate path.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
type fileGetArgs struct {
|
||||
FileID string `json:"file_id" description:"Opaque file ID returned by file_save or file_list."`
|
||||
}
|
||||
|
||||
type fileGetResult struct {
|
||||
Name string `json:"name"`
|
||||
ContentBase64 string `json:"content_base64"`
|
||||
Mime string `json:"mime"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
CreatedAt string `json:"created_at"` // RFC3339
|
||||
}
|
||||
|
||||
// NewFileGet constructs the file_get tool. storage nil → "not
|
||||
// configured" at execute time.
|
||||
func NewFileGet(storage FileStorage) tool.Tool {
|
||||
return tool.NewGatedTool[fileGetArgs](
|
||||
"file_get",
|
||||
"Fetch a saved file by its file_id. Returns name, base64 content, MIME, size, and created_at. The caller must have access to the file's scope (skill / own user: / own run:).",
|
||||
tool.Permission{
|
||||
AuthoringRequirement: tool.RequirementAnyone,
|
||||
OperatesOn: tool.ScopeCaller,
|
||||
SafeForShare: true,
|
||||
Categories: []string{"storage", "read"},
|
||||
},
|
||||
func(ctx context.Context, inv tool.Invocation, args fileGetArgs) (string, error) {
|
||||
if storage == nil {
|
||||
return "", fmt.Errorf("file_get: not configured")
|
||||
}
|
||||
if args.FileID == "" {
|
||||
return "", fmt.Errorf("file_get: file_id required")
|
||||
}
|
||||
|
||||
meta, content, err := storage.FileGet(ctx, args.FileID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrFileNotFound) {
|
||||
return "", fmt.Errorf("file_get: not found")
|
||||
}
|
||||
return "", fmt.Errorf("file_get: %w", err)
|
||||
}
|
||||
|
||||
// Cross-skill access check: a file's SkillID must match the
|
||||
// current invocation's SkillID. Without this, a caller
|
||||
// could probe another skill's file_ids and read content.
|
||||
// One exception — the descendant grant (see
|
||||
// file_descendant_grant.go): workers this run dispatched.
|
||||
grantedViaDescendant := false
|
||||
if meta.SkillID != inv.SkillID {
|
||||
if !descendantFileGrant(ctx, storage, inv, meta.SkillID) {
|
||||
return "", fmt.Errorf("file_get: file does not belong to this skill")
|
||||
}
|
||||
grantedViaDescendant = true
|
||||
}
|
||||
|
||||
// Scope check: even within the same skill, the scope on the
|
||||
// row gates access (e.g. user:bob's file is unreadable by
|
||||
// alice). The descendant grant stands in for it — the file's
|
||||
// scope is the WORKER's run, never the caller's.
|
||||
if err := ValidateScope(inv, meta.Scope, inv.CallerIsAdmin); err != nil && !grantedViaDescendant {
|
||||
return "", fmt.Errorf("file_get: %w", err)
|
||||
}
|
||||
|
||||
res := fileGetResult{
|
||||
Name: meta.Name,
|
||||
ContentBase64: base64.StdEncoding.EncodeToString(content),
|
||||
Mime: meta.MimeType,
|
||||
SizeBytes: meta.SizeBytes,
|
||||
CreatedAt: meta.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
b, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file_get: marshal: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// file_get_metadata returns metadata about a saved file (name, mime,
|
||||
// size, created_at) WITHOUT loading the bytes. This is the v10
|
||||
// agent-friendly companion to file_get — agents that just need to
|
||||
// reason about a file's properties (size, type, name) should use
|
||||
// file_get_metadata instead of pulling the full body into the context
|
||||
// window.
|
||||
//
|
||||
// Why a separate tool (vs adding a flag to file_get): the byte-vs-
|
||||
// reference principle is enforced statically — file_get_metadata's
|
||||
// return shape simply does not carry bytes, so agents and tool
|
||||
// authors can rely on the type signature. A flag-gated variant would
|
||||
// invite "what does include_content=false mean" confusion.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
type fileGetMetadataArgs struct {
|
||||
FileID string `json:"file_id" description:"Opaque file ID returned by file_save or file_list."`
|
||||
}
|
||||
|
||||
type fileGetMetadataResult struct {
|
||||
Name string `json:"name"`
|
||||
Mime string `json:"mime"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
CreatedAt string `json:"created_at"` // RFC3339
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// NewFileGetMetadata constructs the file_get_metadata tool. storage
|
||||
// nil → "not configured" at execute time.
|
||||
func NewFileGetMetadata(storage FileStorage) tool.Tool {
|
||||
return tool.NewGatedTool[fileGetMetadataArgs](
|
||||
"file_get_metadata",
|
||||
"Fetch metadata for a saved file by its file_id (name, mime, size_bytes, created_at, scope). Does NOT load the file bytes — use file_get_text for text content or send_attachments to ship binary content to Discord.",
|
||||
tool.Permission{
|
||||
AuthoringRequirement: tool.RequirementAnyone,
|
||||
OperatesOn: tool.ScopeCaller,
|
||||
SafeForShare: true,
|
||||
Categories: []string{"storage", "read"},
|
||||
},
|
||||
func(ctx context.Context, inv tool.Invocation, args fileGetMetadataArgs) (string, error) {
|
||||
if storage == nil {
|
||||
return "", fmt.Errorf("file_get_metadata: not configured")
|
||||
}
|
||||
if args.FileID == "" {
|
||||
return "", fmt.Errorf("file_get_metadata: file_id required")
|
||||
}
|
||||
meta, _, err := storage.FileGet(ctx, args.FileID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrFileNotFound) {
|
||||
return "", fmt.Errorf("file_get_metadata: not found")
|
||||
}
|
||||
return "", fmt.Errorf("file_get_metadata: %w", err)
|
||||
}
|
||||
// Descendant grant: see file_descendant_grant.go — covers
|
||||
// the scope check too (the file's scope is the worker's run).
|
||||
grantedViaDescendant := false
|
||||
if meta.SkillID != inv.SkillID {
|
||||
if !descendantFileGrant(ctx, storage, inv, meta.SkillID) {
|
||||
return "", fmt.Errorf("file_get_metadata: file does not belong to this skill")
|
||||
}
|
||||
grantedViaDescendant = true
|
||||
}
|
||||
if !grantedViaDescendant {
|
||||
if err := ValidateScope(inv, meta.Scope, inv.CallerIsAdmin); err != nil {
|
||||
return "", fmt.Errorf("file_get_metadata: %w", err)
|
||||
}
|
||||
}
|
||||
res := fileGetMetadataResult{
|
||||
Name: meta.Name,
|
||||
Mime: meta.MimeType,
|
||||
SizeBytes: meta.SizeBytes,
|
||||
CreatedAt: meta.CreatedAt.UTC().Format(time.RFC3339),
|
||||
Scope: meta.Scope,
|
||||
}
|
||||
b, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file_get_metadata: marshal: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// file_get_text fetches a saved text file's content as plain text.
|
||||
// Only succeeds for text/* MIMEs; binary MIMEs return an error so the
|
||||
// agent knows to use a different path (file_get_metadata for
|
||||
// reasoning, send_attachments for delivery).
|
||||
//
|
||||
// Why a 64 KiB cap: the v10 byte-vs-reference principle says inline
|
||||
// text content stays under ~10KB ideally; we set the hard cap at 64
|
||||
// KiB to handle reasonable text artifacts (logs, configs, small
|
||||
// reports) without blowing the agent's context. Files larger than
|
||||
// the cap return an error pointing to send_attachments.
|
||||
//
|
||||
// Why a separate tool (vs file_get): file_get returns base64 +
|
||||
// metadata regardless of MIME, which agents misuse to dump 10MB PDFs
|
||||
// into the context window. file_get_text is the agent-friendly
|
||||
// alternative that explicitly fails fast on binary content.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
const fileGetTextMaxBytes = 64 * 1024
|
||||
|
||||
type fileGetTextArgs struct {
|
||||
FileID string `json:"file_id" description:"Opaque file ID returned by file_save or file_list."`
|
||||
}
|
||||
|
||||
type fileGetTextResult struct {
|
||||
Text string `json:"text"`
|
||||
Mime string `json:"mime"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
CreatedAt string `json:"created_at"` // RFC3339
|
||||
}
|
||||
|
||||
// NewFileGetText constructs the file_get_text tool. storage nil →
|
||||
// "not configured" at execute time.
|
||||
func NewFileGetText(storage FileStorage) tool.Tool {
|
||||
return tool.NewGatedTool[fileGetTextArgs](
|
||||
"file_get_text",
|
||||
"Fetch a saved text file's content (text/* MIMEs only, capped at 64KB). For binary content use file_get_metadata + send_attachments. Errors with 'not_text' for non-text MIMEs and 'too_large' for files > 64KB.",
|
||||
tool.Permission{
|
||||
AuthoringRequirement: tool.RequirementAnyone,
|
||||
OperatesOn: tool.ScopeCaller,
|
||||
SafeForShare: true,
|
||||
Categories: []string{"storage", "read"},
|
||||
},
|
||||
func(ctx context.Context, inv tool.Invocation, args fileGetTextArgs) (string, error) {
|
||||
if storage == nil {
|
||||
return "", fmt.Errorf("file_get_text: not configured")
|
||||
}
|
||||
if args.FileID == "" {
|
||||
return "", fmt.Errorf("file_get_text: file_id required")
|
||||
}
|
||||
meta, content, err := storage.FileGet(ctx, args.FileID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrFileNotFound) {
|
||||
return "", fmt.Errorf("file_get_text: not found")
|
||||
}
|
||||
return "", fmt.Errorf("file_get_text: %w", err)
|
||||
}
|
||||
// Descendant grant: a worker this run (transitively)
|
||||
// dispatched may have produced the file — its scope is the
|
||||
// WORKER's run, so the grant also stands in for the scope
|
||||
// check below.
|
||||
grantedViaDescendant := false
|
||||
if meta.SkillID != inv.SkillID {
|
||||
if !descendantFileGrant(ctx, storage, inv, meta.SkillID) {
|
||||
return "", fmt.Errorf("file_get_text: file does not belong to this skill")
|
||||
}
|
||||
grantedViaDescendant = true
|
||||
}
|
||||
if !grantedViaDescendant {
|
||||
if err := ValidateScope(inv, meta.Scope, inv.CallerIsAdmin); err != nil {
|
||||
return "", fmt.Errorf("file_get_text: %w", err)
|
||||
}
|
||||
}
|
||||
if !isTextMime(meta.MimeType) {
|
||||
return "", fmt.Errorf("file_get_text: not_text: mime %q is not text/*", meta.MimeType)
|
||||
}
|
||||
if int64(len(content)) > fileGetTextMaxBytes {
|
||||
return "", fmt.Errorf("file_get_text: too_large: %d bytes exceeds 64KB cap; use send_attachments to deliver this file to Discord", len(content))
|
||||
}
|
||||
res := fileGetTextResult{
|
||||
Text: string(content),
|
||||
Mime: meta.MimeType,
|
||||
SizeBytes: meta.SizeBytes,
|
||||
CreatedAt: meta.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
b, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file_get_text: marshal: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// isTextMime reports whether the given MIME is a text/* type.
|
||||
// Accepts "text/plain", "text/markdown", "text/csv", "application/json"
|
||||
// and "application/xml" since those are conventionally text.
|
||||
func isTextMime(mime string) bool {
|
||||
mime = strings.ToLower(strings.TrimSpace(mime))
|
||||
if strings.HasPrefix(mime, "text/") {
|
||||
return true
|
||||
}
|
||||
switch mime {
|
||||
case "application/json", "application/xml", "application/xhtml+xml",
|
||||
"application/javascript", "application/yaml", "application/x-yaml":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// file_list returns metadata for files in a scope. Blob bytes are NOT
|
||||
// loaded — listing is a hot path that must stay light, and the LLM
|
||||
// would burn tokens for no benefit.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
type fileListArgs struct {
|
||||
Scope string `json:"scope" description:"Storage scope: 'skill', 'user:<your_id>', or 'run:<run_id>'."`
|
||||
}
|
||||
|
||||
type fileListEntry struct {
|
||||
FileID string `json:"file_id"`
|
||||
Name string `json:"name"`
|
||||
Mime string `json:"mime"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// NewFileList constructs the file_list tool. storage nil → "not
|
||||
// configured" at execute time.
|
||||
func NewFileList(storage FileStorage) tool.Tool {
|
||||
return tool.NewGatedTool[fileListArgs](
|
||||
"file_list",
|
||||
"List files in a scope. Returns a JSON array of {file_id, name, mime, size_bytes, created_at}. Does NOT include bytes — call file_get with a file_id to fetch content.",
|
||||
tool.Permission{
|
||||
AuthoringRequirement: tool.RequirementAnyone,
|
||||
OperatesOn: tool.ScopeCaller,
|
||||
SafeForShare: true,
|
||||
Categories: []string{"storage", "read"},
|
||||
},
|
||||
func(ctx context.Context, inv tool.Invocation, args fileListArgs) (string, error) {
|
||||
if storage == nil {
|
||||
return "", fmt.Errorf("file_list: not configured")
|
||||
}
|
||||
if err := ValidateScope(inv, args.Scope, inv.CallerIsAdmin); err != nil {
|
||||
return "", fmt.Errorf("file_list: %w", err)
|
||||
}
|
||||
// root_run is a KV-only scope (v1) — see file_save's guard.
|
||||
if strings.HasPrefix(args.Scope, "root_run:") {
|
||||
return "", fmt.Errorf("file_list: root_run scope is KV-only")
|
||||
}
|
||||
|
||||
rows, err := storage.FileList(ctx, inv.SkillID, args.Scope)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file_list: %w", err)
|
||||
}
|
||||
|
||||
out := make([]fileListEntry, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, fileListEntry{
|
||||
FileID: r.ID,
|
||||
Name: r.Name,
|
||||
Mime: r.MimeType,
|
||||
SizeBytes: r.SizeBytes,
|
||||
CreatedAt: r.CreatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file_list: marshal: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// file_save persists arbitrary bytes (base64-encoded by the caller)
|
||||
// against a (scope, name) tuple within the calling skill's namespace.
|
||||
// Returns the new file_id, the SHA256 content hash, and the size.
|
||||
//
|
||||
// Why base64 over raw bytes: the LLM's tool-call wire format is JSON,
|
||||
// which can't carry arbitrary bytes natively. Base64 round-trips
|
||||
// cleanly through the schema.
|
||||
//
|
||||
// Why hash + size in the response: agents commonly want to dedup
|
||||
// across runs (same hash = same content) or build a manifest. Reporting
|
||||
// these inline saves an immediate file_get round-trip just to compute
|
||||
// them.
|
||||
//
|
||||
// Per-file cap: maxFileBytes (constructor arg) enforces an upper bound
|
||||
// on individual file size. 0 falls back to defaultFileMaxBytes (10 MB).
|
||||
//
|
||||
// Per-skill quota (sum across all files): the constructor's QuotaProvider
|
||||
// arg drives the v4 Phase 4 enforcement. nil disables enforcement
|
||||
// (useful for tests and admin-only deployments). The check is:
|
||||
//
|
||||
// used := storage.FileUsageBytes(skill)
|
||||
// if used + len(new content) > filesMax → quota_exceeded
|
||||
//
|
||||
// Note we do NOT subtract a "prior" value here the way kv_set does:
|
||||
// file_save always inserts a new file row (content-addressable dedup
|
||||
// is at the blob layer, not the row layer), so every save is additive
|
||||
// to FileUsageBytes.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
const defaultFileMaxBytes = 16 * 1024 * 1024 // 10 MiB
|
||||
|
||||
type fileSaveArgs struct {
|
||||
Scope string `json:"scope" description:"Storage scope: 'skill' (shared across all callers of this skill), 'user:<your_id>' (per-caller), or 'run:<run_id>' (this run's scratchpad)."`
|
||||
Name string `json:"name" description:"Filename including extension. Used for display only — the file is identified by an opaque file_id."`
|
||||
ContentBase64 string `json:"content_base64" description:"Base64-encoded file content."`
|
||||
Mime string `json:"mime,omitempty" description:"Optional MIME type. If omitted, detected from the first 512 bytes of content."`
|
||||
}
|
||||
|
||||
type fileSaveResult struct {
|
||||
FileID string `json:"file_id"`
|
||||
Hash string `json:"hash"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
// NewFileSave constructs the file_save tool.
|
||||
//
|
||||
// storage nil → "not configured" at execute time.
|
||||
// maxFileBytes <= 0 falls back to defaultFileMaxBytes (10 MiB).
|
||||
// quota nil → per-skill quota check skipped (per-file cap still applies).
|
||||
//
|
||||
// Permission: anyone may author; safe for share. Scope check at handler
|
||||
// entry prevents cross-user writes; per-user buckets are isolated by
|
||||
// inv.CallerID.
|
||||
func NewFileSave(storage FileStorage, quota QuotaProvider, maxFileBytes int) tool.Tool {
|
||||
if maxFileBytes <= 0 {
|
||||
maxFileBytes = defaultFileMaxBytes
|
||||
}
|
||||
return tool.NewGatedTool[fileSaveArgs](
|
||||
"file_save",
|
||||
"Save base64-encoded bytes against a (scope, name) tuple. Returns file_id (opaque), SHA256 hash, and size_bytes. Content is dedup'd by hash — multiple file_save calls with identical bytes share storage. NOTE: for files produced inside code_exec, do NOT hand-encode base64 here (it corrupts) — write them to /workspace/ in the code_exec call and use the files_out file_id it returns.",
|
||||
tool.Permission{
|
||||
AuthoringRequirement: tool.RequirementAnyone,
|
||||
OperatesOn: tool.ScopeCaller,
|
||||
SafeForShare: true,
|
||||
Categories: []string{"storage", "write"},
|
||||
},
|
||||
func(ctx context.Context, inv tool.Invocation, args fileSaveArgs) (string, error) {
|
||||
if storage == nil {
|
||||
return "", fmt.Errorf("file_save: not configured")
|
||||
}
|
||||
if err := ValidateScope(inv, args.Scope, inv.CallerIsAdmin); err != nil {
|
||||
return "", fmt.Errorf("file_save: %w", err)
|
||||
}
|
||||
// root_run is a KV-only scope (v1): file storage partitions
|
||||
// by the calling skill, so a root_run file would silently be
|
||||
// invisible to siblings AND escape the run-scope sweeper.
|
||||
// Reject loudly instead.
|
||||
if strings.HasPrefix(args.Scope, "root_run:") {
|
||||
return "", fmt.Errorf("file_save: root_run scope is KV-only; save under run:<run_id> and share the file_id via kv_set in the root_run scope")
|
||||
}
|
||||
if args.Name == "" {
|
||||
return "", fmt.Errorf("file_save: name required")
|
||||
}
|
||||
if args.ContentBase64 == "" {
|
||||
return "", fmt.Errorf("file_save: content_base64 required")
|
||||
}
|
||||
|
||||
// Decode + cap. Decoding twice (once to count, once to
|
||||
// store) would waste cycles; we decode once and check size
|
||||
// after.
|
||||
content, err := base64.StdEncoding.DecodeString(args.ContentBase64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file_save: invalid base64: %w", err)
|
||||
}
|
||||
if len(content) > maxFileBytes {
|
||||
return "", fmt.Errorf("file_save: file exceeds max %d bytes (got %d)", maxFileBytes, len(content))
|
||||
}
|
||||
|
||||
// Per-skill quota gate (v4 Phase 4). Skipped when quota is nil
|
||||
// (tests / admin opt-out) so the per-file cap above is the
|
||||
// only line of defence in that mode.
|
||||
if quota != nil {
|
||||
_, filesMax, err := quota.EffectiveQuota(ctx, inv.SkillID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file_save: quota lookup: %w", err)
|
||||
}
|
||||
used, err := storage.FileUsageBytes(ctx, inv.SkillID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file_save: usage check: %w", err)
|
||||
}
|
||||
if used+int64(len(content)) > filesMax {
|
||||
return "", fmt.Errorf("file_save: quota_exceeded — %d/%d bytes used; ask admin for higher quota", used, filesMax)
|
||||
}
|
||||
}
|
||||
|
||||
// SHA256 for content-addressable dedup at the storage layer.
|
||||
h := sha256.Sum256(content)
|
||||
hashHex := hex.EncodeToString(h[:])
|
||||
|
||||
mime := args.Mime
|
||||
if mime == "" {
|
||||
// http.DetectContentType is documented to read at most
|
||||
// the first 512 bytes; passing the full slice is fine.
|
||||
mime = http.DetectContentType(content)
|
||||
}
|
||||
|
||||
meta := FileDomainMeta{
|
||||
ID: uuid.NewString(),
|
||||
SkillID: inv.SkillID,
|
||||
Scope: args.Scope,
|
||||
Name: args.Name,
|
||||
ContentHash: hashHex,
|
||||
MimeType: mime,
|
||||
SizeBytes: int64(len(content)),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
fileID, err := storage.FileSave(ctx, meta, content)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file_save: %w", err)
|
||||
}
|
||||
|
||||
res := fileSaveResult{
|
||||
FileID: fileID,
|
||||
Hash: hashHex,
|
||||
SizeBytes: int64(len(content)),
|
||||
}
|
||||
b, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file_save: marshal result: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// file_search runs a token-AND search over the per-skill (or, for
|
||||
// admin authors, cross-skill) file index. Returns up to N matches with
|
||||
// {file_id, name, snippet, score}.
|
||||
//
|
||||
// Why admin-authoring only: a public skill could otherwise probe
|
||||
// other skills' file content via cross-skill search. Restricting the
|
||||
// tool's authoring requirement to admins blocks shared/public skills
|
||||
// from depending on file_search at all (it never appears in their
|
||||
// allowed-tool catalog at save time). Within a private skill,
|
||||
// admin-authored or otherwise, scope is per-call: the handler always
|
||||
// pins skill_id to inv.SkillID — no matter what the LLM-supplied scope
|
||||
// arg says — so a non-admin caller invoking an admin-authored public
|
||||
// skill cannot escape the skill's own bucket.
|
||||
//
|
||||
// Why use Storage's SearchFiles directly: token logic + scoring lives
|
||||
// in the skills package. The handler is a thin transcoder.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
// FileSearcher is the narrow surface the file_search tool needs.
|
||||
// Production wiring (mort.go) bridges *skills.System.Storage().
|
||||
// nil-safe: a nil FileSearcher surfaces "not configured" at the first
|
||||
// call.
|
||||
type FileSearcher interface {
|
||||
SearchFiles(ctx context.Context, skillID, scope, query string, limit int) ([]FileSearchDomainHit, error)
|
||||
}
|
||||
|
||||
// FileSearchDomainHit mirrors skills.FileSearchHit (cycle-break domain
|
||||
// shape). The production adapter is a struct copy.
|
||||
type FileSearchDomainHit struct {
|
||||
FileID string
|
||||
SkillID string
|
||||
Scope string
|
||||
Name string
|
||||
MimeType string
|
||||
Snippet string
|
||||
Score int
|
||||
}
|
||||
|
||||
type fileSearchArgs struct {
|
||||
Query string `json:"query" description:"Free-text search query. Tokenised, lowercased, ANDed."`
|
||||
Scope string `json:"scope,omitempty" description:"Optional storage scope to restrict the search ('skill', 'user:<your_id>', 'run:<run_id>'). Empty = all scopes within this skill."`
|
||||
Limit int `json:"limit,omitempty" description:"Optional max hits to return (default 25, max 100)."`
|
||||
}
|
||||
|
||||
type fileSearchHit struct {
|
||||
FileID string `json:"file_id"`
|
||||
Name string `json:"name"`
|
||||
Mime string `json:"mime,omitempty"`
|
||||
Snippet string `json:"snippet,omitempty"`
|
||||
Score int `json:"score"`
|
||||
}
|
||||
|
||||
// NewFileSearch constructs the file_search tool. Authoring-required
|
||||
// admin so non-admins can't include this tool in shared/public skills
|
||||
// (the share-safety check rejects share+admin-only as private-only).
|
||||
//
|
||||
// Wait — if the tool is admin-authoring AND share-safe, an admin could
|
||||
// author a public skill that uses it. That's the desired flow: admin
|
||||
// curates the skill, but the privacy property still holds because the
|
||||
// handler PINS skill_id to inv.SkillID. A non-admin caller of the
|
||||
// public skill can ONLY search files within that skill's bucket, not
|
||||
// cross-skill.
|
||||
//
|
||||
// Setting SafeForShare=false would force this tool to be private-only;
|
||||
// that's needlessly restrictive. The privacy property comes from the
|
||||
// per-call skill_id pin, not from share-time gating.
|
||||
func NewFileSearch(searcher FileSearcher) tool.Tool {
|
||||
return tool.NewGatedTool[fileSearchArgs](
|
||||
"file_search",
|
||||
"Full-text search over this skill's saved files. Returns array of {file_id, name, snippet, score} ordered by score desc. Tokens are lowercased + ANDed. Admin-authored only — non-admin callers of an admin-authored public skill still see only that skill's files.",
|
||||
tool.Permission{
|
||||
AuthoringRequirement: tool.RequirementAdmin,
|
||||
OperatesOn: tool.ScopeCaller,
|
||||
SafeForShare: true,
|
||||
Categories: []string{"storage", "read"},
|
||||
},
|
||||
func(ctx context.Context, inv tool.Invocation, args fileSearchArgs) (string, error) {
|
||||
if searcher == nil {
|
||||
return "", fmt.Errorf("file_search: not configured")
|
||||
}
|
||||
if args.Query == "" {
|
||||
return "", fmt.Errorf("file_search: query required")
|
||||
}
|
||||
limit := args.Limit
|
||||
if limit <= 0 {
|
||||
limit = 25
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
scope := args.Scope
|
||||
if scope != "" {
|
||||
if err := ValidateScope(inv, scope, inv.CallerIsAdmin); err != nil {
|
||||
return "", fmt.Errorf("file_search: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Pin skill_id to the invoking skill — even if the LLM
|
||||
// supplies a different value somewhere, the handler always
|
||||
// scopes to inv.SkillID. This is the privacy guarantee
|
||||
// referenced in the package doc.
|
||||
rows, err := searcher.SearchFiles(ctx, inv.SkillID, scope, args.Query, limit)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file_search: %w", err)
|
||||
}
|
||||
out := make([]fileSearchHit, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, fileSearchHit{
|
||||
FileID: r.FileID,
|
||||
Name: r.Name,
|
||||
Mime: r.MimeType,
|
||||
Snippet: r.Snippet,
|
||||
Score: r.Score,
|
||||
})
|
||||
}
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file_search: marshal: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// file_storage.go declares the narrow FileStorage interface that the
|
||||
// four v4 file tools (file_save, file_get, file_list, file_delete)
|
||||
// need at execute time.
|
||||
//
|
||||
// Why a narrow interface (vs importing pkg/logic/skills directly): same
|
||||
// cycle constraint as kv_storage.go — pkg/logic/skills imports
|
||||
// pkg/skilltools, so we mirror the FileMeta shape here and let
|
||||
// pkg/logic/mort.go adapt at wiring time.
|
||||
//
|
||||
// FileDomainMeta is field-for-field with skills.FileMeta; the production
|
||||
// adapter is a struct copy.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FileStorage is the narrow surface file tools need from the skills
|
||||
// package. Production wiring (mort.go) bridges *skills.System.Storage().
|
||||
// nil-safe: tools constructed against a nil FileStorage surface "not
|
||||
// configured" at the first call.
|
||||
type FileStorage interface {
|
||||
FileSave(ctx context.Context, meta FileDomainMeta, content []byte) (string, error)
|
||||
FileGet(ctx context.Context, fileID string) (*FileDomainMeta, []byte, error)
|
||||
FileList(ctx context.Context, skillID, scope string) ([]FileDomainMeta, error)
|
||||
FileDelete(ctx context.Context, fileID string) error
|
||||
FileUsageBytes(ctx context.Context, skillID string) (int64, error)
|
||||
}
|
||||
|
||||
// FileDomainMeta mirrors skills.FileMeta. Field-for-field; the
|
||||
// production adapter is a struct copy.
|
||||
type FileDomainMeta struct {
|
||||
ID string // UUID, the public file_id
|
||||
SkillID string
|
||||
Scope string
|
||||
Name string
|
||||
ContentHash string // SHA256 hex
|
||||
MimeType string
|
||||
SizeBytes int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ErrFileNotFound mirrors skills.ErrFileNotFound. The production
|
||||
// adapter returns this sentinel when wrapping a skills.ErrFileNotFound;
|
||||
// tools detect it with errors.Is to surface a "not_found" string to the
|
||||
// LLM rather than a generic error.
|
||||
var ErrFileNotFound = errors.New("file: not found")
|
||||
@@ -0,0 +1,73 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/run"
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tools"
|
||||
)
|
||||
|
||||
// TestExecutorRunsToolUsingAgent is the end-to-end proof that a host can
|
||||
// register a generic tool and the executor runs an agent that CALLS it: the
|
||||
// fake model emits a `think` tool call, the executor dispatches it through the
|
||||
// registered tool, then the model finalises. Exercises the full tool-dispatch
|
||||
// loop + step instrumentation.
|
||||
func TestExecutorRunsToolUsingAgent(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
if err := tools.Register(reg); err != nil {
|
||||
t.Fatalf("register tools: %v", err)
|
||||
}
|
||||
|
||||
fp := fake.New("fake")
|
||||
fp.Enqueue("test-model",
|
||||
// Step 1: the model decides to call `think`.
|
||||
fake.ReplyWith(llm.Response{
|
||||
ToolCalls: []llm.ToolCall{{
|
||||
ID: "call-1",
|
||||
Name: "think",
|
||||
Arguments: json.RawMessage(`{"thought":"plan: answer briefly"}`),
|
||||
}},
|
||||
}),
|
||||
// Step 2: with the tool result in hand, the model finalises.
|
||||
fake.Reply("all done"),
|
||||
)
|
||||
m, err := fp.Model("test-model")
|
||||
if err != nil {
|
||||
t.Fatalf("fake model: %v", err)
|
||||
}
|
||||
|
||||
ex := run.New(run.Config{
|
||||
Registry: reg,
|
||||
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) {
|
||||
return ctx, m, nil
|
||||
},
|
||||
})
|
||||
|
||||
res := ex.Run(context.Background(),
|
||||
run.RunnableAgent{Name: "thinker", ModelTier: "test-model", LowLevelTools: []string{"think"}},
|
||||
tool.Invocation{RunID: "run-tool-1", CallerID: "c"},
|
||||
"do the thing")
|
||||
|
||||
if res.Err != nil {
|
||||
t.Fatalf("run error: %v", res.Err)
|
||||
}
|
||||
if res.Output != "all done" {
|
||||
t.Fatalf("output = %q, want %q", res.Output, "all done")
|
||||
}
|
||||
// The step instrumentation should have captured the think call.
|
||||
var sawThink bool
|
||||
for _, s := range res.Steps {
|
||||
if s.Title == "think" {
|
||||
sawThink = true
|
||||
}
|
||||
}
|
||||
if !sawThink {
|
||||
t.Errorf("expected a `think` step in Result.Steps, got %d steps: %+v", len(res.Steps), res.Steps)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// kv_delete removes a single entry by (scope, key). Missing rows
|
||||
// surface as the literal string "not_found" rather than an error so the
|
||||
// LLM can reason "did this row exist?" without wrapping the call in
|
||||
// error handling.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
type kvDeleteArgs struct {
|
||||
Scope string `json:"scope" description:"Storage scope: 'skill', 'user:<your_id>', 'run:<run_id>', or 'root_run:<root_run_id>'."`
|
||||
Key string `json:"key" description:"Key within the scope."`
|
||||
}
|
||||
|
||||
// NewKVDelete constructs the kv_delete tool. storage nil → "not
|
||||
// configured" at execute time.
|
||||
func NewKVDelete(storage KVStorage) tool.Tool {
|
||||
return tool.NewGatedTool[kvDeleteArgs](
|
||||
"kv_delete",
|
||||
"Remove an entry by (scope, key). Returns 'ok' on success or 'not_found' if no row matched.",
|
||||
tool.Permission{
|
||||
AuthoringRequirement: tool.RequirementAnyone,
|
||||
OperatesOn: tool.ScopeCaller,
|
||||
SafeForShare: true,
|
||||
Categories: []string{"storage", "write"},
|
||||
},
|
||||
func(ctx context.Context, inv tool.Invocation, args kvDeleteArgs) (string, error) {
|
||||
if storage == nil {
|
||||
return "", fmt.Errorf("kv_delete: not configured")
|
||||
}
|
||||
if err := ValidateScope(inv, args.Scope, inv.CallerIsAdmin); err != nil {
|
||||
return "", fmt.Errorf("kv_delete: %w", err)
|
||||
}
|
||||
if args.Key == "" {
|
||||
return "", fmt.Errorf("kv_delete: key required")
|
||||
}
|
||||
|
||||
if err := storage.KVDelete(ctx, kvPartition(inv, args.Scope), args.Scope, args.Key); err != nil {
|
||||
if errors.Is(err, ErrKVNotFound) {
|
||||
return "not_found", nil
|
||||
}
|
||||
return "", fmt.Errorf("kv_delete: %w", err)
|
||||
}
|
||||
return "ok", nil
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// kv_get is the v4 KV-storage read tool. It looks up a single value by
|
||||
// (scope, key) within the calling skill's KV namespace and returns the
|
||||
// stored JSON value, or `null` when no row matches.
|
||||
//
|
||||
// Why "null" on miss (vs an error): the LLM's most natural use is
|
||||
// "fetch this if cached, otherwise compute and store". Miss-as-error
|
||||
// would force the agent to wrap every call in error handling; miss-as-
|
||||
// null collapses the happy path.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
type kvGetArgs struct {
|
||||
Scope string `json:"scope" description:"Storage scope: 'skill' (shared across all callers of this skill), 'user:<your_id>' (per-caller), 'run:<run_id>' (this run's scratchpad), or 'root_run:<root_run_id>' (shared scratchpad of this whole dispatch tree — use to coordinate with parallel sibling workers)."`
|
||||
Key string `json:"key" description:"Key within the scope."`
|
||||
}
|
||||
|
||||
// NewKVGet constructs the kv_get tool. storage may be nil — the tool
|
||||
// then surfaces "not configured" at execute time instead of failing
|
||||
// registration.
|
||||
//
|
||||
// Permission: anyone may author; safe for share. The scope check at
|
||||
// handler entry makes share-safety meaningful — a shared skill cannot
|
||||
// read another caller's `user:<id>` bucket because ValidateScope
|
||||
// rejects that.
|
||||
func NewKVGet(storage KVStorage) tool.Tool {
|
||||
return tool.NewGatedTool[kvGetArgs](
|
||||
"kv_get",
|
||||
"Look up a value by key in this skill's storage. Returns the stored JSON value, or `null` if no row matches the (scope, key) tuple.",
|
||||
tool.Permission{
|
||||
AuthoringRequirement: tool.RequirementAnyone,
|
||||
OperatesOn: tool.ScopeCaller,
|
||||
SafeForShare: true,
|
||||
Categories: []string{"storage", "read"},
|
||||
},
|
||||
func(ctx context.Context, inv tool.Invocation, args kvGetArgs) (string, error) {
|
||||
if storage == nil {
|
||||
return "", fmt.Errorf("kv_get: not configured")
|
||||
}
|
||||
if err := ValidateScope(inv, args.Scope, inv.CallerIsAdmin); err != nil {
|
||||
return "", fmt.Errorf("kv_get: %w", err)
|
||||
}
|
||||
if args.Key == "" {
|
||||
return "", fmt.Errorf("kv_get: key required")
|
||||
}
|
||||
|
||||
entry, err := storage.KVGet(ctx, kvPartition(inv, args.Scope), args.Scope, args.Key)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrKVNotFound) {
|
||||
return "null", nil
|
||||
}
|
||||
return "", fmt.Errorf("kv_get: %w", err)
|
||||
}
|
||||
return string(entry.Value), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// kv_list returns metadata (key, size, expiry) for entries within a
|
||||
// scope, optionally filtered by key prefix. Values are NOT loaded —
|
||||
// listing is a hot path that should stay light, and dumping every
|
||||
// value byte into the LLM context would burn tokens for no benefit.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
const (
|
||||
kvListDefaultLimit = 100
|
||||
kvListMaxLimit = 1000
|
||||
)
|
||||
|
||||
type kvListArgs struct {
|
||||
Scope string `json:"scope" description:"Storage scope: 'skill', 'user:<your_id>', 'run:<run_id>', or 'root_run:<root_run_id>'."`
|
||||
Prefix string `json:"prefix,omitempty" description:"Optional key-prefix filter. Empty matches all keys in the scope."`
|
||||
Limit int `json:"limit,omitempty" description:"Max entries to return. Default 100, hard cap 1000."`
|
||||
}
|
||||
|
||||
type kvListEntry struct {
|
||||
Key string `json:"key"`
|
||||
SizeBytes int `json:"size_bytes"`
|
||||
// ExpiresAt is RFC3339 when set, "" otherwise. JSON serialised this
|
||||
// way so the LLM can reason about it as a string field consistently
|
||||
// (rather than null vs. missing key).
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// NewKVList constructs the kv_list tool. storage nil → "not configured"
|
||||
// at execute time.
|
||||
func NewKVList(storage KVStorage) tool.Tool {
|
||||
return tool.NewGatedTool[kvListArgs](
|
||||
"kv_list",
|
||||
"List keys + sizes + expiries in a scope (optionally filtered by key prefix). Returns a JSON array. Does NOT include values — call kv_get to fetch a specific value.",
|
||||
tool.Permission{
|
||||
AuthoringRequirement: tool.RequirementAnyone,
|
||||
OperatesOn: tool.ScopeCaller,
|
||||
SafeForShare: true,
|
||||
Categories: []string{"storage", "read"},
|
||||
},
|
||||
func(ctx context.Context, inv tool.Invocation, args kvListArgs) (string, error) {
|
||||
if storage == nil {
|
||||
return "", fmt.Errorf("kv_list: not configured")
|
||||
}
|
||||
if err := ValidateScope(inv, args.Scope, inv.CallerIsAdmin); err != nil {
|
||||
return "", fmt.Errorf("kv_list: %w", err)
|
||||
}
|
||||
|
||||
limit := args.Limit
|
||||
if limit <= 0 {
|
||||
limit = kvListDefaultLimit
|
||||
}
|
||||
if limit > kvListMaxLimit {
|
||||
limit = kvListMaxLimit
|
||||
}
|
||||
|
||||
rows, err := storage.KVList(ctx, kvPartition(inv, args.Scope), args.Scope, args.Prefix, limit)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("kv_list: %w", err)
|
||||
}
|
||||
|
||||
out := make([]kvListEntry, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
e := kvListEntry{
|
||||
Key: r.Key,
|
||||
SizeBytes: len(r.Value),
|
||||
}
|
||||
if r.ExpiresAt != nil {
|
||||
e.ExpiresAt = r.ExpiresAt.Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("kv_list: marshal: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
// kv_set is the v4 KV-storage write tool. It upserts (scope, key) →
|
||||
// value within the calling skill's namespace, with optional TTL.
|
||||
//
|
||||
// Per-value cap: the constructor takes maxValueBytes (typically read
|
||||
// from convar `skills.storage.kv_max_value_bytes`); 0 means use the
|
||||
// 64 KiB default.
|
||||
//
|
||||
// Per-skill quota (sum across all rows): the constructor's QuotaProvider
|
||||
// arg drives the v4 Phase 4 enforcement. nil disables enforcement
|
||||
// (useful for tests and admin-only deployments). The check is:
|
||||
//
|
||||
// used := storage.KVUsageBytes(skill)
|
||||
// delta := len(new value) - len(prior value if updating same key)
|
||||
// if used + delta > kvMax → quota_exceeded
|
||||
//
|
||||
// We subtract the existing value's size on UPDATE so an in-place edit
|
||||
// of a hot key never trips the cap unless the new value is larger.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
const defaultKVMaxValueBytes = 65536 // 64 KiB
|
||||
|
||||
type kvSetArgs struct {
|
||||
Scope string `json:"scope" description:"Storage scope: 'skill', 'user:<your_id>', 'run:<run_id>', or 'root_run:<root_run_id>' (shared across the whole dispatch tree)."`
|
||||
Key string `json:"key" description:"Key within the scope."`
|
||||
Value json.RawMessage `json:"value" description:"JSON value to store. Must parse as valid JSON (object, array, string, number, bool, or null)."`
|
||||
TTLSeconds *int `json:"ttl_seconds,omitempty" description:"Optional TTL in seconds. The entry expires (and is lazy-purged on read) after this duration."`
|
||||
}
|
||||
|
||||
// NewKVSet constructs the kv_set tool.
|
||||
//
|
||||
// storage nil → "not configured" at execute time.
|
||||
// maxValueBytes <= 0 falls back to defaultKVMaxValueBytes.
|
||||
// quota nil → per-skill quota check is skipped (per-value cap still
|
||||
// applies).
|
||||
func NewKVSet(storage KVStorage, quota QuotaProvider, maxValueBytes int) tool.Tool {
|
||||
if maxValueBytes <= 0 {
|
||||
maxValueBytes = defaultKVMaxValueBytes
|
||||
}
|
||||
return tool.NewGatedTool[kvSetArgs](
|
||||
"kv_set",
|
||||
"Set a value at the given scope+key. Optionally with a TTL after which the entry auto-expires.",
|
||||
tool.Permission{
|
||||
AuthoringRequirement: tool.RequirementAnyone,
|
||||
OperatesOn: tool.ScopeCaller,
|
||||
SafeForShare: true,
|
||||
Categories: []string{"storage", "write"},
|
||||
},
|
||||
func(ctx context.Context, inv tool.Invocation, args kvSetArgs) (string, error) {
|
||||
if storage == nil {
|
||||
return "", fmt.Errorf("kv_set: not configured")
|
||||
}
|
||||
if err := ValidateScope(inv, args.Scope, inv.CallerIsAdmin); err != nil {
|
||||
return "", fmt.Errorf("kv_set: %w", err)
|
||||
}
|
||||
if args.Key == "" {
|
||||
return "", fmt.Errorf("kv_set: key required")
|
||||
}
|
||||
if len(args.Value) == 0 {
|
||||
return "", fmt.Errorf("kv_set: value required")
|
||||
}
|
||||
if len(args.Value) > maxValueBytes {
|
||||
return "", fmt.Errorf("kv_set: value exceeds max %d bytes (got %d)", maxValueBytes, len(args.Value))
|
||||
}
|
||||
|
||||
// Validate JSON. The storage layer treats the raw bytes as
|
||||
// opaque, but the LLM contract says "value is a JSON value"
|
||||
// — surfacing a parse error here gives a friendlier message
|
||||
// than letting an invalid blob round-trip and confuse the
|
||||
// reader on a future kv_get.
|
||||
var probe any
|
||||
if err := json.Unmarshal(args.Value, &probe); err != nil {
|
||||
return "", fmt.Errorf("kv_set: value is not valid JSON: %w", err)
|
||||
}
|
||||
|
||||
partition := kvPartition(inv, args.Scope)
|
||||
|
||||
// Per-skill quota gate (v4 Phase 4). Skipped when quota is nil
|
||||
// (tests / admin opt-out) so the per-value cap above is the
|
||||
// only line of defence in that mode. Also skipped for the
|
||||
// shared root_run partition — per-skill quota attribution is
|
||||
// meaningless across the sentinel; the per-value cap above +
|
||||
// the run-scope sweeper bound that partition's growth.
|
||||
if quota != nil && partition == inv.SkillID {
|
||||
kvMax, _, err := quota.EffectiveQuota(ctx, inv.SkillID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("kv_set: quota lookup: %w", err)
|
||||
}
|
||||
used, err := storage.KVUsageBytes(ctx, inv.SkillID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("kv_set: usage check: %w", err)
|
||||
}
|
||||
delta := int64(len(args.Value))
|
||||
// On UPDATE, subtract the prior value's size so an
|
||||
// in-place edit of a hot key doesn't double-count. A
|
||||
// brand-new key (KVGet returns ErrKVNotFound) leaves
|
||||
// delta untouched.
|
||||
if existing, getErr := storage.KVGet(ctx, inv.SkillID, args.Scope, args.Key); getErr == nil && existing != nil {
|
||||
delta -= int64(len(existing.Value))
|
||||
} else if getErr != nil && !errors.Is(getErr, ErrKVNotFound) {
|
||||
return "", fmt.Errorf("kv_set: pre-write lookup: %w", getErr)
|
||||
}
|
||||
if used+delta > kvMax {
|
||||
return "", fmt.Errorf("kv_set: quota_exceeded — %d/%d bytes used; ask admin for higher quota", used, kvMax)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
entry := KVDomainEntry{
|
||||
SkillID: partition,
|
||||
Scope: args.Scope,
|
||||
Key: args.Key,
|
||||
Value: args.Value,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if args.TTLSeconds != nil && *args.TTLSeconds > 0 {
|
||||
expires := now.Add(time.Duration(*args.TTLSeconds) * time.Second)
|
||||
entry.ExpiresAt = &expires
|
||||
}
|
||||
|
||||
if err := storage.KVSet(ctx, entry); err != nil {
|
||||
return "", fmt.Errorf("kv_set: %w", err)
|
||||
}
|
||||
// V7 versioned KV history (admin diagnostic). Best-effort —
|
||||
// a failed history write must NOT shadow the successful
|
||||
// kv_set return, so we ignore the error after logging.
|
||||
// Production adapter satisfies KVHistoryRecorder; tests
|
||||
// using a bare KVStorage skip this branch entirely.
|
||||
if h, ok := storage.(KVHistoryRecorder); ok && h != nil {
|
||||
_ = h.RecordKVHistory(ctx, partition, args.Scope, args.Key, []byte(args.Value), inv.CallerID)
|
||||
}
|
||||
return "ok", nil
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// kv_storage.go declares the narrow KV-storage interface that the four
|
||||
// KV tools (kv_get, kv_set, kv_list, kv_delete) need at execute time.
|
||||
//
|
||||
// Why a narrow interface (vs importing pkg/logic/skills directly):
|
||||
// pkg/logic/skills imports pkg/skilltools (for Invocation + Tool), so
|
||||
// importing skills back here would form an import cycle. Production
|
||||
// wiring (pkg/logic/mort.go, deferred) will supply a concrete adapter
|
||||
// that wraps `*skills.System.Storage()` and translates between
|
||||
// skills.KVEntry and the local KVDomainEntry shape.
|
||||
//
|
||||
// Why a *separate* domain shape (KVDomainEntry) vs reusing skills.KVEntry:
|
||||
// the cycle break has to be complete — even importing the type would
|
||||
// pull skills into skilltools/tools' import graph. The two shapes mirror
|
||||
// each other field-for-field; the adapter is a trivial struct copy.
|
||||
//
|
||||
// The same pattern is used by skill_invoke.go (SkillInvokerProvider).
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
// kvPartition picks the skill_id partition for a KV operation. KV rows
|
||||
// are keyed (skill_id, scope, key); for the shared `root_run:<id>`
|
||||
// scope, every run in a dispatch tree — including ephemeral workers
|
||||
// with distinct agent IDs — must land in ONE partition or siblings
|
||||
// could never see each other's writes. The sentinel
|
||||
// tool.RootRunKVPartition is that shared partition; isolation
|
||||
// between trees is preserved because the scope string embeds the root
|
||||
// run id, which ValidateScope checks against inv.RootRunID.
|
||||
func kvPartition(inv tool.Invocation, scope string) string {
|
||||
if strings.HasPrefix(scope, "root_run:") {
|
||||
return tool.RootRunKVPartition
|
||||
}
|
||||
return inv.SkillID
|
||||
}
|
||||
|
||||
// KVStorage is the narrow surface KV tools need from the skills package.
|
||||
// nil-safe: tools constructed against a nil KVStorage surface a clean
|
||||
// "not configured" error at the first call rather than crashing.
|
||||
type KVStorage interface {
|
||||
KVGet(ctx context.Context, skillID, scope, key string) (*KVDomainEntry, error)
|
||||
KVSet(ctx context.Context, e KVDomainEntry) error
|
||||
KVList(ctx context.Context, skillID, scope, prefix string, limit int) ([]KVDomainEntry, error)
|
||||
KVDelete(ctx context.Context, skillID, scope, key string) error
|
||||
KVUsageBytes(ctx context.Context, skillID string) (int64, error)
|
||||
}
|
||||
|
||||
// KVHistoryRecorder is the OPTIONAL post-write hook for the v7
|
||||
// versioned KV history. The kv_set tool checks for this interface via
|
||||
// type assertion; production storage adapters that satisfy it write a
|
||||
// history row AFTER a successful KVSet.
|
||||
//
|
||||
// Why optional (vs adding to KVStorage): existing test fakes don't
|
||||
// need to grow a method. Production wires the real adapter which
|
||||
// satisfies the interface; tests that don't care about history skip
|
||||
// the implementation entirely.
|
||||
//
|
||||
// Why only on success: a failed KVSet leaves no skill_kv row to refer
|
||||
// to; appending a history entry would create an orphan record of a
|
||||
// change that didn't happen.
|
||||
type KVHistoryRecorder interface {
|
||||
RecordKVHistory(ctx context.Context, skillID, scope, key string, value []byte, changedBy string) error
|
||||
}
|
||||
|
||||
// KVDomainEntry mirrors skills.KVEntry without pulling in the cycle.
|
||||
// Field-for-field with the skills package's KVEntry; the production
|
||||
// adapter is a struct copy.
|
||||
type KVDomainEntry struct {
|
||||
SkillID string
|
||||
Scope string // "skill" | "user:<id>" | "run:<id>"
|
||||
Key string
|
||||
Value json.RawMessage
|
||||
ExpiresAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// ErrKVNotFound mirrors skills.ErrKVNotFound. The production adapter
|
||||
// returns this sentinel when wrapping a skills.ErrKVNotFound; tools
|
||||
// detect it with errors.Is to surface "not_found" to the LLM rather
|
||||
// than a generic error.
|
||||
var ErrKVNotFound = errors.New("kv: not found")
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
// nowParams is the LLM-facing param struct for current_time / now.
|
||||
//
|
||||
// Why optional `timezone`: most agent prompts know the user's local
|
||||
// timezone (it's in the chatbot's system prompt) but the agent has
|
||||
// no way to override on a per-call basis. An explicit arg lets a
|
||||
// research skill ask "what time is it in NYC for the user reading
|
||||
// this report?" without needing access to a member-config lookup
|
||||
// tool.
|
||||
type nowParams struct {
|
||||
Timezone string `json:"timezone,omitempty" description:"Optional IANA timezone name (e.g. 'America/Chicago', 'Europe/London'). Defaults to the calling user's configured timezone, falling back to UTC."`
|
||||
}
|
||||
|
||||
// nowResponse is the JSON envelope returned to the agent.
|
||||
//
|
||||
// Why a structured shape: the v1 tool returned a markdown blob.
|
||||
// Agents that needed just the year had to substring-parse, which
|
||||
// fails on locale variations. JSON lets the agent pick the field
|
||||
// it cares about.
|
||||
type nowResponse struct {
|
||||
NowISO string `json:"now_iso"`
|
||||
NowHuman string `json:"now_human"`
|
||||
Timezone string `json:"timezone"`
|
||||
Weekday string `json:"weekday"`
|
||||
Year int `json:"year"`
|
||||
Month int `json:"month"`
|
||||
Day int `json:"day"`
|
||||
Hour int `json:"hour"`
|
||||
Minute int `json:"minute"`
|
||||
Second int `json:"second"`
|
||||
Warning string `json:"warning,omitempty"`
|
||||
}
|
||||
|
||||
// NewNow constructs the v11 current_time / now tool. The provider
|
||||
// supplies the calling member's configured timezone (per-user
|
||||
// localisation). nil falls back to UTC.
|
||||
//
|
||||
// V11 keeps the registered tool name "now" for back-compat with the
|
||||
// existing tool catalog tests AND adds the same tool surface under
|
||||
// the agent-facing description "current time". The design spec
|
||||
// called the tool "current_time" but the v1 registry already used
|
||||
// "now" — switching the registry name would break stored skills'
|
||||
// `tools` lists. Same name, expanded behaviour.
|
||||
func NewNow(provider CurrentTimeProvider) tool.Tool {
|
||||
return tool.NewGatedTool[nowParams](
|
||||
"now",
|
||||
"Return the current time. Optional 'timezone' (IANA name e.g. 'America/Chicago'); defaults to the calling user's configured timezone or UTC. Returns ISO + human-readable formats plus structured year/month/day/weekday for time-relative reasoning. Use this BEFORE assuming a year — the agent's knowledge cut-off may differ from real time.",
|
||||
tool.Permission{
|
||||
AuthoringRequirement: tool.RequirementAnyone,
|
||||
OperatesOn: tool.ScopeGlobal,
|
||||
SafeForShare: true,
|
||||
Categories: []string{"utility"},
|
||||
},
|
||||
func(ctx context.Context, inv tool.Invocation, p nowParams) (string, error) {
|
||||
tzName := strings.TrimSpace(p.Timezone)
|
||||
warning := ""
|
||||
if tzName == "" && provider != nil {
|
||||
tzName = provider.UserTimezone(ctx, inv.CallerID)
|
||||
}
|
||||
if tzName == "" {
|
||||
tzName = "UTC"
|
||||
}
|
||||
loc, err := time.LoadLocation(tzName)
|
||||
if err != nil {
|
||||
warning = fmt.Sprintf("unknown timezone %q; falling back to UTC", tzName)
|
||||
tzName = "UTC"
|
||||
loc = time.UTC
|
||||
}
|
||||
t := time.Now().In(loc)
|
||||
out := nowResponse{
|
||||
NowISO: t.Format(time.RFC3339),
|
||||
NowHuman: t.Format("Monday, January 2, 2006 at 3:04 PM MST"),
|
||||
Timezone: tzName,
|
||||
Weekday: t.Weekday().String(),
|
||||
Year: t.Year(),
|
||||
Month: int(t.Month()),
|
||||
Day: t.Day(),
|
||||
Hour: t.Hour(),
|
||||
Minute: t.Minute(),
|
||||
Second: t.Second(),
|
||||
Warning: warning,
|
||||
}
|
||||
b, mErr := json.Marshal(out)
|
||||
if mErr != nil {
|
||||
return "", fmt.Errorf("now: marshal: %w", mErr)
|
||||
}
|
||||
return string(b), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// quota_provider.go declares the narrow QuotaProvider interface used by
|
||||
// kv_set and file_save to enforce per-skill byte quotas at write time.
|
||||
//
|
||||
// Why a narrow interface (vs importing pkg/logic/skills directly): same
|
||||
// cycle constraint as kv_storage.go and file_storage.go — pkg/logic/skills
|
||||
// already imports pkg/skilltools, so importing skills back here would
|
||||
// form an import cycle. Production wiring (pkg/logic/mort.go) supplies
|
||||
// *skills.System, which satisfies QuotaProvider via its EffectiveQuota
|
||||
// method.
|
||||
//
|
||||
// Why a separate interface vs adding the method to KVStorage/FileStorage:
|
||||
// quota resolution is a system-level policy (combining override + convar
|
||||
// + default), not a pure storage read. Keeping it separate lets a tool
|
||||
// constructor accept a nil QuotaProvider when an integrator wants to
|
||||
// skip enforcement (e.g. an admin-only skill that bypasses caps).
|
||||
package tools
|
||||
|
||||
import "context"
|
||||
|
||||
// QuotaProvider returns effective per-skill quotas for the storage
|
||||
// tools' write-path enforcement. Production wires *skills.System, which
|
||||
// satisfies this via its EffectiveQuota method.
|
||||
//
|
||||
// nil-safe: tools constructed against a nil QuotaProvider do NOT enforce
|
||||
// per-skill quotas. That mode is useful for tests and for environments
|
||||
// where quota enforcement is intentionally disabled.
|
||||
type QuotaProvider interface {
|
||||
// EffectiveQuota returns the effective KV and file byte caps for the
|
||||
// skill. The two values resolve admin overrides + convar defaults +
|
||||
// package constants in that order.
|
||||
EffectiveQuota(ctx context.Context, skillID string) (kvMax, filesMax int64, err error)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DefaultResearchConfig returns a ResearchConfig pinned to the v11
|
||||
// design defaults. Production wiring overrides via a convar-aware
|
||||
// adapter; tests use the defaults directly.
|
||||
func DefaultResearchConfig() ResearchConfig {
|
||||
return defaultResearchConfig{}
|
||||
}
|
||||
|
||||
type defaultResearchConfig struct{}
|
||||
|
||||
func (defaultResearchConfig) MaxInlineBytes(_ context.Context) int { return 12 * 1024 }
|
||||
func (defaultResearchConfig) PDFMaxPages(_ context.Context) int { return 50 }
|
||||
func (defaultResearchConfig) WebSearchEnabled(_ context.Context) bool { return true }
|
||||
func (defaultResearchConfig) WebSearchMaxPerRun(_ context.Context) int { return 10 }
|
||||
func (defaultResearchConfig) ReadPageMaxPerRun(_ context.Context) int { return 10 }
|
||||
func (defaultResearchConfig) VideoMaxPerRun(_ context.Context) int { return 5 }
|
||||
func (defaultResearchConfig) VerifyURLMaxPerRun(_ context.Context) int { return 20 }
|
||||
func (defaultResearchConfig) ReadPDFMaxPerRun(_ context.Context) int { return 5 }
|
||||
func (defaultResearchConfig) HTTPGetMaxPerRun(_ context.Context) int { return 20 }
|
||||
func (defaultResearchConfig) HTTPPostMaxPerRun(_ context.Context) int { return 20 }
|
||||
func (defaultResearchConfig) WebSearchAugmentThreshold(_ context.Context) int { return 5 }
|
||||
|
||||
// InMemorySearchBudget is the package-default SearchBudget — a
|
||||
// simple per-(run,kind) counter held in a map. NOT
|
||||
// production-correct because the map persists across the process
|
||||
// lifetime; production wiring MUST plug a per-run reset.
|
||||
//
|
||||
// Why a default at all: tests want a working SearchBudget without
|
||||
// rolling their own. Documenting the production-correctness gap
|
||||
// here keeps the production adapter (in mort.go) honest.
|
||||
type InMemorySearchBudget struct {
|
||||
cap map[string]int // by kind; "" means "use Default"
|
||||
|
||||
mu sync.Mutex
|
||||
counts map[string]int // key = runID+"|"+kind
|
||||
}
|
||||
|
||||
// NewInMemorySearchBudget constructs a default SearchBudget. Pass a
|
||||
// per-kind cap map (e.g. {"web_search": 10, "read_page": 10}); kinds
|
||||
// missing from the map fall back to maxPerKindDefault.
|
||||
func NewInMemorySearchBudget(caps map[string]int) *InMemorySearchBudget {
|
||||
if caps == nil {
|
||||
caps = map[string]int{}
|
||||
}
|
||||
return &InMemorySearchBudget{
|
||||
cap: caps,
|
||||
counts: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
// CheckAndIncrement implements SearchBudget. Returns the count AFTER
|
||||
// incrementing on success; the counter is NOT incremented when the
|
||||
// call would exceed the cap (so a "search_budget_exceeded" rejection
|
||||
// doesn't burn budget on retry).
|
||||
func (b *InMemorySearchBudget) CheckAndIncrement(_ context.Context, runID, kind string) (int, int, bool) {
|
||||
max := b.cap[kind]
|
||||
if max <= 0 {
|
||||
max = 10 // safe default
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
key := runID + "|" + kind
|
||||
cur := b.counts[key]
|
||||
if cur >= max {
|
||||
return cur, max, true
|
||||
}
|
||||
b.counts[key] = cur + 1
|
||||
return cur + 1, max, false
|
||||
}
|
||||
|
||||
// ResetRun is a test helper: clears the counters for a single run
|
||||
// across all kinds. Production wiring uses its own per-run lifecycle
|
||||
// (the executor's RunFinalizer interface).
|
||||
func (b *InMemorySearchBudget) ResetRun(runID string) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
prefix := runID + "|"
|
||||
for k := range b.counts {
|
||||
if len(k) > len(prefix) && k[:len(prefix)] == prefix {
|
||||
delete(b.counts, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StaticTimeProvider is the package-default CurrentTimeProvider —
|
||||
// returns "" for every member (the tool then falls back to UTC).
|
||||
// Tests that need a specific timezone wire a one-line struct.
|
||||
type StaticTimeProvider struct{}
|
||||
|
||||
// UserTimezone implements CurrentTimeProvider with a flat fallback to "".
|
||||
func (StaticTimeProvider) UserTimezone(_ context.Context, _ string) string { return "" }
|
||||
@@ -0,0 +1,332 @@
|
||||
// Package tools — research provider plumbing for v11.
|
||||
//
|
||||
// This file declares the narrow interfaces v11's research tools
|
||||
// (web_search, read_page, read_video, read_pdf, verify_url, etc.) need
|
||||
// at execute time. Production wiring lives in pkg/logic/mort.go and
|
||||
// closes over the searcher chain, the extractor / chromedp client, the
|
||||
// PDF extractor, and the yt-dlp wrapper.
|
||||
//
|
||||
// Why narrow interfaces (vs importing pkg/logic/searcher / extractor
|
||||
// directly): the same cycle-break pattern used by KVStorage, FileStorage,
|
||||
// HTTPConfigProvider — keeps pkg/skilltools/tools free of the wiring
|
||||
// layer so tests can stub each dependency. Each provider is nil-safe:
|
||||
// the tool surfaces "not configured" at first call rather than failing
|
||||
// at registration.
|
||||
//
|
||||
// Test: each tool under pkg/skilltools/tools/ wired against these
|
||||
// interfaces has its own *_test.go using the in-package fakes in
|
||||
// research_providers_fakes_test.go.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PageCache is the narrow surface read_page (and read_pdf) consult to
|
||||
// avoid re-fetching the same URL within the cache's TTL. Production
|
||||
// wiring bridges this interface to the legacy *cache.Cache held by
|
||||
// pkg/logic/query.System so a `.query foo.com` and a
|
||||
// `.skill query foo.com` for the same URL share one cache slot.
|
||||
//
|
||||
// Why a narrow interface (vs importing the cache package directly):
|
||||
// same cycle-break pattern as KVStorage / FileStorage / CitationStorage
|
||||
// — keeps pkg/skilltools/tools free of the wiring layer. The legacy
|
||||
// cache slot key is `sha256(url)`; the production adapter is
|
||||
// responsible for hashing so this interface stays clean (raw URL in/out)
|
||||
// and skill-tool authors never need to know the slot shape.
|
||||
//
|
||||
// nil-safe: a tool constructed with a nil PageCache simply skips the
|
||||
// cache layer (always treat Get as a miss; Set is a no-op).
|
||||
//
|
||||
// Test: tests pass a fake PageCache that records Get/Set calls and
|
||||
// returns canned hits. See page_cache_test.go for the read_page hit /
|
||||
// miss scenarios.
|
||||
type PageCache interface {
|
||||
// Get returns the cached body for urlStr and true on hit, or
|
||||
// (nil, false) on miss. Implementations MUST treat any backing-
|
||||
// store error as a miss (best-effort, never fail the caller).
|
||||
Get(ctx context.Context, urlStr string) ([]byte, bool)
|
||||
|
||||
// Set writes body under the slot for urlStr with the supplied TTL.
|
||||
// Implementations MUST swallow backing-store errors (best-effort
|
||||
// caching is correct: a write failure should not propagate to the
|
||||
// agent loop).
|
||||
Set(ctx context.Context, urlStr string, body []byte, ttl time.Duration)
|
||||
}
|
||||
|
||||
// PageCacheTTL is the default TTL applied by tools that consult a
|
||||
// PageCache. Mirrors the legacy `query.pageCacheTTL` constant
|
||||
// (1 hour) so a `.query`-warmed slot reads back from a `.skill query`
|
||||
// (and vice versa) within the same window.
|
||||
//
|
||||
// Tools that want a different TTL pass an explicit value to
|
||||
// PageCache.Set; this constant is the project default the v11 / v-research
|
||||
// tools all use.
|
||||
const PageCacheTTL = 1 * time.Hour
|
||||
|
||||
// PageExtractor is the narrow surface read_page needs at execute
|
||||
// time. The production adapter wraps mort's existing extractor
|
||||
// (Ollama web_fetch first, chromedp fallback on JS-heavy pages).
|
||||
//
|
||||
// nil-safe: a tool constructed with a nil PageExtractor surfaces
|
||||
// "not configured" at first call.
|
||||
//
|
||||
// Why: read_page used to be a thin io.ReadAll over the URL — it
|
||||
// missed JS rendering, didn't honour the v6 page cache, and could
|
||||
// not surface the underlying provider name. v11 routes through this
|
||||
// interface so the production wiring (mort.go) can plug in the
|
||||
// existing query-side extractor without exposing query.Agent.
|
||||
type PageExtractor interface {
|
||||
// ExtractPage fetches and extracts readable text from urlStr.
|
||||
// Returns the extracted body, a final URL (after any redirects
|
||||
// the extractor followed), the provider name ("ollama" |
|
||||
// "chromedp" | "ytdlp"), and an error.
|
||||
//
|
||||
// The returned body is the FULL extracted text — callers apply
|
||||
// the v10 byte-vs-reference cap before surfacing to the agent.
|
||||
//
|
||||
// bypassCache=true skips any page cache and forces a fresh
|
||||
// extraction. Default false.
|
||||
ExtractPage(ctx context.Context, urlStr string, bypassCache bool) (text string, finalURL string, provider string, err error)
|
||||
}
|
||||
|
||||
// VideoTranscriber is the narrow surface read_video needs at
|
||||
// execute time. Production wiring wraps internal/ytdlp.
|
||||
//
|
||||
// nil-safe: tool surfaces "not configured" at first call.
|
||||
//
|
||||
// Why a separate interface from PageExtractor: video is a different
|
||||
// shape (transcript + metadata) and a different binary (yt-dlp).
|
||||
// Keeping them distinct lets tests stub each independently.
|
||||
type VideoTranscriber interface {
|
||||
// ExtractVideoTranscript returns the transcript text and the
|
||||
// best-effort metadata (title, duration in seconds, channel).
|
||||
// Implementations MUST return a non-empty transcript or an
|
||||
// error — empty-transcript success is interpreted by the tool
|
||||
// as a "transcript_unavailable" failure.
|
||||
ExtractVideoTranscript(ctx context.Context, urlStr string) (transcript string, meta VideoMeta, err error)
|
||||
}
|
||||
|
||||
// VideoMeta is best-effort metadata returned alongside a video
|
||||
// transcript. Any field may be empty/zero if the implementation
|
||||
// could not extract it.
|
||||
type VideoMeta struct {
|
||||
Title string
|
||||
Channel string
|
||||
DurationSeconds int
|
||||
}
|
||||
|
||||
// PDFFetcher is the narrow surface read_pdf needs at execute time.
|
||||
// Production wiring uses an HTTP-aware fetcher that HEAD-validates
|
||||
// content-type before downloading the body.
|
||||
//
|
||||
// nil-safe: tool surfaces "not configured" at first call.
|
||||
//
|
||||
// Why: a tool that just embedded PDF extraction would couple
|
||||
// fetching + parsing. Splitting the fetch (allowlist + SSRF +
|
||||
// HEAD check) from the extract (page-level parsing) keeps each
|
||||
// step testable and lets the same fetcher serve verify_url one
|
||||
// day if we want a PDF-aware fast path.
|
||||
type PDFFetcher interface {
|
||||
// FetchPDF downloads the PDF at urlStr (after HEAD-validating
|
||||
// content-type) and returns the raw bytes plus the final URL.
|
||||
// HEAD-validation rejects a URL whose Content-Type is not a
|
||||
// PDF mime AND whose path does not end in .pdf.
|
||||
FetchPDF(ctx context.Context, urlStr string) (body []byte, finalURL string, err error)
|
||||
}
|
||||
|
||||
// PDFExtractor parses PDF bytes into plain text + page count.
|
||||
// Production wires internal.ExtractPDFText.
|
||||
//
|
||||
// Why split from PDFFetcher: tests want to vary the fetch (mock
|
||||
// server returning bytes) without rebuilding the extractor.
|
||||
type PDFExtractor interface {
|
||||
// ExtractPDFText returns the concatenated plain-text content
|
||||
// of the PDF along with the page count. The caller applies any
|
||||
// per-page cap and the v10 byte-vs-reference cap on the result.
|
||||
ExtractPDFText(ctx context.Context, body []byte, maxPages int) (text string, pageCount int, truncated bool, err error)
|
||||
}
|
||||
|
||||
// HEADChecker is the narrow surface verify_url needs at execute
|
||||
// time. Production wiring uses the same SSRF-pinned transport as
|
||||
// http_get so the security envelope is consistent.
|
||||
//
|
||||
// Why a separate interface (vs reusing HTTPConfigProvider+doHTTP):
|
||||
// verify_url's contract is simpler — HEAD only, no body bytes
|
||||
// returned, and the agent only cares about reachable / status /
|
||||
// final URL / content-type. A bespoke surface lets the production
|
||||
// adapter optimise for that path (no body buffer, no body close).
|
||||
type HEADChecker interface {
|
||||
// HEAD performs a HEAD request against urlStr (with SSRF +
|
||||
// allowlist enforcement) and returns the final URL after any
|
||||
// redirects, the HTTP status code, and the Content-Type header.
|
||||
// Returns reachable=false with a non-nil err for transport
|
||||
// failures (DNS, TCP, allowlist rejection); reachable=true with
|
||||
// any HTTP status (including 4xx/5xx) is the success shape —
|
||||
// the agent decides whether the URL is "real".
|
||||
HEAD(ctx context.Context, urlStr string) (finalURL string, status int, contentType string, reachable bool, err error)
|
||||
}
|
||||
|
||||
// CitationStorage is the narrow surface cite() needs at execute
|
||||
// time. Production wires *skills.System.Storage(); tests stub.
|
||||
//
|
||||
// nil-safe: tool surfaces "not configured" at first call.
|
||||
//
|
||||
// Why a narrow interface (vs importing pkg/logic/skills): same
|
||||
// cycle constraint as KVStorage / FileStorage. Production adapter
|
||||
// in mort.go bridges to skills.Storage's RecordCitation /
|
||||
// ListCitations methods AND a separate URL-history tracker.
|
||||
//
|
||||
// Two responsibilities, deliberately separate:
|
||||
//
|
||||
// 1. RecordCitation writes a row into skill_run_sources — this is
|
||||
// the user-visible citations table for the Sources panel and
|
||||
// CSV export. ONLY rows the agent successfully cited via
|
||||
// cite() land here.
|
||||
// 2. RecordURLTouch / GetTouchedURLs maintains a per-run set of
|
||||
// URLs the agent has interacted with (web_search results,
|
||||
// read_page input, read_pdf input, read_video input). cite()
|
||||
// reads this set to reject claims for URLs the agent never
|
||||
// touched. This set lives in a different table or scope from
|
||||
// the citations table — it's working state, not a record.
|
||||
type CitationStorage interface {
|
||||
// RecordCitation appends one (run_id, url, claim, cited_at)
|
||||
// row to the citations table (skill_run_sources). cited_at is
|
||||
// set by the storage layer to time.Now() when zero. The caller
|
||||
// has already verified the URL is in the touched-URL set
|
||||
// (via GetTouchedURLs); this method is the persistence step.
|
||||
RecordCitation(ctx context.Context, runID, url, claim string) error
|
||||
|
||||
// RecordURLTouch records that the agent has interacted with
|
||||
// `url` during `runID`. Called by web_search (per result),
|
||||
// read_page, read_pdf, and read_video. Idempotent — repeat
|
||||
// calls for the same (run_id, url) are no-ops at the storage
|
||||
// layer.
|
||||
RecordURLTouch(ctx context.Context, runID, url string) error
|
||||
|
||||
// GetTouchedURLs returns the set of URLs the run has
|
||||
// interacted with. Used by cite() to verify that a claim's
|
||||
// URL is one the agent actually visited. Empty for a fresh
|
||||
// run — cite() then rejects every claim with
|
||||
// "url_not_in_run_history".
|
||||
GetTouchedURLs(ctx context.Context, runID string) (map[string]struct{}, error)
|
||||
|
||||
// ListCitations returns all citations recorded for the run, in
|
||||
// insertion order. Powers the /skills/{id}/runs/{run_id}
|
||||
// Sources panel.
|
||||
ListCitations(ctx context.Context, runID string) ([]CitationRow, error)
|
||||
}
|
||||
|
||||
// CitationRow mirrors the skill_run_sources row shape. Fields
|
||||
// match the spec: run_id is implicit in the query, url + claim are
|
||||
// what the agent submitted, cited_at is the wall-clock timestamp
|
||||
// at insert.
|
||||
type CitationRow struct {
|
||||
URL string
|
||||
Claim string
|
||||
CitedAt int64 // unix-seconds; storage adapter normalises from time.Time
|
||||
}
|
||||
|
||||
// CurrentTimeProvider exposes a "now" + per-user timezone lookup.
|
||||
// Production wiring closes over the bot's member-config getter.
|
||||
//
|
||||
// nil-safe: a tool constructed with a nil provider falls back to
|
||||
// server-time + UTC (current behaviour of NewNow before v11).
|
||||
type CurrentTimeProvider interface {
|
||||
// UserTimezone returns the IANA timezone name configured for
|
||||
// the given Discord member ID, or "" when the member has no
|
||||
// timezone configured. Empty fallback is "UTC".
|
||||
UserTimezone(ctx context.Context, memberID string) string
|
||||
}
|
||||
|
||||
// SearchBudget is the narrow surface web_search reads at execute
|
||||
// time to honour skills.web_search.max_per_run.
|
||||
//
|
||||
// Production wiring closes over a per-run counter held by the
|
||||
// executor. nil-safe: tool falls back to a built-in package
|
||||
// counter (process-wide, NOT per-run) — useful for tests but NOT
|
||||
// production-correct because budget bleeds across runs. The
|
||||
// production adapter MUST be wired.
|
||||
type SearchBudget interface {
|
||||
// CheckAndIncrement returns the current count AFTER incrementing
|
||||
// for the given runID, the configured max, and an error when
|
||||
// the call would exceed the cap. The handler returns a clean
|
||||
// "search_budget_exceeded" string on exceed (not an error so
|
||||
// the agent can react).
|
||||
CheckAndIncrement(ctx context.Context, runID, kind string) (count, max int, exceeded bool)
|
||||
}
|
||||
|
||||
// ResearchConfig is the narrow surface that read_page / read_video /
|
||||
// read_pdf / verify_url read at execute time for per-tool budget caps
|
||||
// and inline-vs-file_id thresholds. Production wiring closes over
|
||||
// the relevant convars.
|
||||
//
|
||||
// nil-safe: tools fall back to package defaults.
|
||||
type ResearchConfig interface {
|
||||
// MaxInlineBytes returns the cap above which extracted text is
|
||||
// persisted as a file_id under run-scope (v10 byte-vs-reference
|
||||
// principle). Default 12 KiB.
|
||||
MaxInlineBytes(ctx context.Context) int
|
||||
|
||||
// PDFMaxPages returns the cap on pages extracted from a PDF
|
||||
// before truncation. Default 50.
|
||||
PDFMaxPages(ctx context.Context) int
|
||||
|
||||
// WebSearchEnabled is the master switch for web_search.
|
||||
WebSearchEnabled(ctx context.Context) bool
|
||||
|
||||
// WebSearchMaxPerRun is the per-run search cap.
|
||||
WebSearchMaxPerRun(ctx context.Context) int
|
||||
|
||||
// ReadPageMaxPerRun is the per-run page-read cap.
|
||||
ReadPageMaxPerRun(ctx context.Context) int
|
||||
|
||||
// VideoMaxPerRun is the per-run video-read cap.
|
||||
VideoMaxPerRun(ctx context.Context) int
|
||||
|
||||
// VerifyURLMaxPerRun is the per-run HEAD-check cap.
|
||||
VerifyURLMaxPerRun(ctx context.Context) int
|
||||
|
||||
// ReadPDFMaxPerRun is the per-run PDF-read cap.
|
||||
ReadPDFMaxPerRun(ctx context.Context) int
|
||||
|
||||
// HTTPGetMaxPerRun (v15.2) is the per-run http_get cap. The agent
|
||||
// otherwise can retry-storm through random URLs and bloat its own
|
||||
// context with each tool result. Default 20.
|
||||
HTTPGetMaxPerRun(ctx context.Context) int
|
||||
|
||||
// HTTPPostMaxPerRun (v15.2) is the per-run http_post cap. Default 20.
|
||||
HTTPPostMaxPerRun(ctx context.Context) int
|
||||
|
||||
// WebSearchAugmentThreshold is the minimum number of primary
|
||||
// (Ollama) results required to skip the secondary (DDG/Brave)
|
||||
// search. When the primary backend returns fewer than this many
|
||||
// results, the augmented searcher also queries the secondary and
|
||||
// merges both result sets. Default 5.
|
||||
WebSearchAugmentThreshold(ctx context.Context) int
|
||||
|
||||
// ReplyChainDepthMax is unused here; placeholder shape for
|
||||
// future per-tool caps. Kept off this interface — callers reach
|
||||
// into the convar reader directly when they need it.
|
||||
}
|
||||
|
||||
// ErrPageExtractionFailed is the sentinel returned by a PageExtractor
|
||||
// when both Ollama and chromedp paths produce empty content.
|
||||
var ErrPageExtractionFailed = errors.New("page extraction failed: empty content")
|
||||
|
||||
// ErrVideoTranscriptUnavailable is the sentinel returned by a
|
||||
// VideoTranscriber when no captions / transcript could be obtained.
|
||||
var ErrVideoTranscriptUnavailable = errors.New("video transcript unavailable")
|
||||
|
||||
// ErrPDFNotPDF is the sentinel returned by a PDFFetcher when the
|
||||
// HEAD response indicates a non-PDF content-type AND the URL path
|
||||
// has no .pdf extension. Surfaces a clean "url_is_not_a_pdf"
|
||||
// rejection rather than a generic transport error.
|
||||
var ErrPDFNotPDF = errors.New("url does not serve a PDF")
|
||||
|
||||
// ErrPDFEncrypted is returned by a PDFExtractor when the PDF refuses
|
||||
// extraction because it is password-protected. Surfaces a clean
|
||||
// "pdf_encrypted" rejection.
|
||||
var ErrPDFEncrypted = errors.New("pdf is encrypted")
|
||||
@@ -0,0 +1,113 @@
|
||||
// scope_validate.go centralises the storage-scope authorisation check
|
||||
// shared by every v4 KV and file tool. It enforces:
|
||||
//
|
||||
// - "skill" — always allowed (the skill's shared, cross-caller area).
|
||||
// - "user:<callerID>" — allowed if it matches inv.CallerID (or admin).
|
||||
// - "user:<other>" — allowed only for admin callers.
|
||||
// - "run:<runID>" — allowed if it matches inv.RunID (or admin).
|
||||
// - "run:<other>" — allowed only for admin callers.
|
||||
// - "root_run:<id>" — allowed if it matches inv.RootRunID (or admin):
|
||||
// the dispatch tree's SHARED scratchpad, readable
|
||||
// and writable by every run under one root
|
||||
// (parallel sibling workers coordinate here).
|
||||
// - any other shape — rejected with a descriptive error.
|
||||
//
|
||||
// Why a single helper (vs inline checks in each tool): the parsing rules
|
||||
// must match exactly across kv_get/set/list/delete and file_save/get/
|
||||
// list/delete. Centralising them means one place to fix when the
|
||||
// vocabulary evolves and one place for the test matrix.
|
||||
//
|
||||
// Why the isAdmin parameter: the v4 Invocation does NOT carry an
|
||||
// admin flag — the executor sets inv.CallerIsAdmin via the host AdminPolicy; tools pass it through
|
||||
// parameter exists for tests (which exercise the admin paths) and for a
|
||||
// future Invocation extension that adds an admin signal without
|
||||
// breaking this helper's signature.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
// ValidateScope rejects scope strings the caller is not authorised to
|
||||
// access. See file-level doc for the exact ruleset.
|
||||
//
|
||||
// Why isAdmin is parameterised: tests pass true to verify admin paths;
|
||||
// production tools currently always pass false because Invocation
|
||||
// doesn't carry admin status. The gate is "you can access your own
|
||||
// scope only" until a future extension threads an admin signal through
|
||||
// the executor.
|
||||
func ValidateScope(inv tool.Invocation, scope string, isAdmin bool) error {
|
||||
if scope == "skill" {
|
||||
return nil
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(scope, "user:"); ok {
|
||||
if rest == "" {
|
||||
return fmt.Errorf("scope: empty user id after 'user:'")
|
||||
}
|
||||
if rest == inv.CallerID {
|
||||
return nil
|
||||
}
|
||||
if isAdmin {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("scope %q: cannot access another user's storage", scope)
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(scope, "root_run:"); ok {
|
||||
if rest == "" {
|
||||
return fmt.Errorf("scope: empty run id after 'root_run:'")
|
||||
}
|
||||
// The dispatch tree's shared scratchpad. Every run in one tree
|
||||
// carries the same RootRunID (stamped by both executors from the
|
||||
// dispatchguard chain), so siblings spawned in parallel — even
|
||||
// ephemeral workers with distinct agent IDs — validate against
|
||||
// the same scope string. Storage-side, root_run scopes live in
|
||||
// the shared RootRunKVPartition; this check is the isolation
|
||||
// boundary between trees.
|
||||
if rest == inv.RootRunID && inv.RootRunID != "" {
|
||||
return nil
|
||||
}
|
||||
if isAdmin {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("scope %q: cannot access another dispatch tree's storage", scope)
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(scope, "run:"); ok {
|
||||
if rest == "" {
|
||||
return fmt.Errorf("scope: empty run id after 'run:'")
|
||||
}
|
||||
if rest == inv.RunID {
|
||||
return nil
|
||||
}
|
||||
// V10: when this run is a reply continuation, the agent may
|
||||
// access the PARENT run's scope. The parent's run-scope KV is
|
||||
// the natural carrier for "ask user a question, save state,
|
||||
// resume on reply" — without this access, every continuation
|
||||
// would have to re-derive state from parent_output alone.
|
||||
// Note: the parent's run-scope is subject to the v4
|
||||
// auto-purge (24h after parent finished). Long-delayed replies
|
||||
// will see an empty scope.
|
||||
if inv.Continuation != nil && rest == inv.Continuation.ParentRunID {
|
||||
return nil
|
||||
}
|
||||
// V14: when this run is invoked via skill_invoke /
|
||||
// skill_invoke_parallel from a parent skill, the agent may
|
||||
// access the PARENT run's scope. This is the natural carrier
|
||||
// for the "scout fans out, parent reads consolidated state"
|
||||
// pattern that deepresearch uses — research-scout writes its
|
||||
// touched-URL list under run:<parent_run_id> and the parent
|
||||
// reads it back during the investigate phase. Without this
|
||||
// access, every parent/child handoff would have to be
|
||||
// serialised through tool-result strings.
|
||||
if inv.ParentRunID != "" && rest == inv.ParentRunID {
|
||||
return nil
|
||||
}
|
||||
if isAdmin {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("scope %q: cannot access another run's storage", scope)
|
||||
}
|
||||
return fmt.Errorf("scope %q: unknown shape; expected 'skill', 'user:<id>', 'run:<id>', or 'root_run:<id>'", scope)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
// StoreDeps wires the persistent-memory tools (kv_* and file_*). A host
|
||||
// supplies its KV and/or File backends; the kv group registers only when KV is
|
||||
// set and the file group only when Files is set, so a host can take just one.
|
||||
// Everything else has a sensible default:
|
||||
//
|
||||
// - Quota defaults to a generous static cap (a host that meters per-skill
|
||||
// storage supplies its own QuotaProvider).
|
||||
// - FileSearch / Minter+BaseURL are optional — file_search and
|
||||
// create_file_url register only when wired.
|
||||
// - MaxValueBytes / MaxFileBytes default when non-positive.
|
||||
type StoreDeps struct {
|
||||
KV KVStorage
|
||||
Files FileStorage
|
||||
Quota QuotaProvider
|
||||
FileSearch FileSearcher
|
||||
Minter FileTokenMinter
|
||||
BaseURL string
|
||||
|
||||
MaxValueBytes int // kv_set per-value cap; default 256 KiB
|
||||
MaxFileBytes int // file_save per-file cap; default 16 MiB
|
||||
}
|
||||
|
||||
// RegisterStore registers the kv_* tools (when KV is set) and the file_* tools
|
||||
// (when Files is set). At least one of KV/Files is required.
|
||||
func RegisterStore(reg tool.Registry, d StoreDeps) error {
|
||||
if d.KV == nil && d.Files == nil {
|
||||
return errors.New("tools: RegisterStore needs at least KV or Files")
|
||||
}
|
||||
if d.Quota == nil {
|
||||
d.Quota = staticQuota{kvMax: 64 << 20, filesMax: 1 << 30}
|
||||
}
|
||||
if d.MaxValueBytes <= 0 {
|
||||
d.MaxValueBytes = 256 << 10
|
||||
}
|
||||
if d.MaxFileBytes <= 0 {
|
||||
d.MaxFileBytes = 16 << 20
|
||||
}
|
||||
|
||||
var ts []tool.Tool
|
||||
if d.KV != nil {
|
||||
ts = append(ts,
|
||||
NewKVGet(d.KV), NewKVSet(d.KV, d.Quota, d.MaxValueBytes),
|
||||
NewKVList(d.KV), NewKVDelete(d.KV),
|
||||
)
|
||||
}
|
||||
if d.Files != nil {
|
||||
ts = append(ts,
|
||||
NewFileSave(d.Files, d.Quota, d.MaxFileBytes),
|
||||
NewFileGet(d.Files), NewFileGetText(d.Files), NewFileGetMetadata(d.Files),
|
||||
NewFileList(d.Files), NewFileDelete(d.Files),
|
||||
)
|
||||
if d.FileSearch != nil {
|
||||
ts = append(ts, NewFileSearch(d.FileSearch))
|
||||
}
|
||||
if d.Minter != nil && d.BaseURL != "" {
|
||||
ts = append(ts, NewCreateFileURL(d.Minter, d.Files, d.BaseURL))
|
||||
}
|
||||
}
|
||||
return registerAll(reg, ts...)
|
||||
}
|
||||
|
||||
// staticQuota is the default QuotaProvider: a fixed KV/file byte cap for every
|
||||
// skill. A host that needs per-skill metering supplies its own.
|
||||
type staticQuota struct{ kvMax, filesMax int64 }
|
||||
|
||||
func (q staticQuota) EffectiveQuota(context.Context, string) (kvMax, filesMax int64, err error) {
|
||||
return q.kvMax, q.filesMax, nil
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Package tools — v12 summarize.
|
||||
//
|
||||
// One fast-tier LLM call: text in → concise text summary out. Either
|
||||
// `text` or `file_id` (mutually exclusive) supplies the source. Per-run
|
||||
// budget enforced via the existing v11 SearchBudget surface (kind=
|
||||
// "summarize"); per-skill cost accounting via the meta-LLM helper's
|
||||
// ledger (skill_llm_meta_calls).
|
||||
//
|
||||
// Why a dedicated tool (vs reusing summary_summarise): summary_
|
||||
// summarise wraps the URL-summary pipeline used by /summary; it's
|
||||
// over-coupled to a specific extraction flow. v12's summarize is the
|
||||
// "given any text, give me a summary" primitive that downstream tools
|
||||
// (read_page → summarize, extract → summarize) can compose freely.
|
||||
//
|
||||
// File-id input path: when the caller supplies file_id, we dereference
|
||||
// via FileStorage. Cross-skill check rejects stolen IDs (matching
|
||||
// file_get's pattern). Scope check denies user:bob's file from alice's
|
||||
// invocation.
|
||||
//
|
||||
// Test: summarize_test.go covers happy path (mock helper), file_id
|
||||
// input, oversize input truncation, budget exceeded, focus-arg
|
||||
// pass-through, cross-skill file_id rejection, and the
|
||||
// missing-both-args validation.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/llmmeta"
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
// summarizeMaxInputBytes is the hard input cap. Inputs longer than
|
||||
// this are truncated with a `truncated=true` flag in the response so
|
||||
// the agent knows the summary covers a prefix.
|
||||
const summarizeMaxInputBytes = 32 * 1024
|
||||
|
||||
// summarizeDefaultMaxWords is the default max_words when the caller
|
||||
// doesn't supply one. Capped further by skills.summarize.max_words.
|
||||
const summarizeDefaultMaxWords = 200
|
||||
|
||||
// summarizeFallbackMaxWords is the cap used when SummarizeConfig is nil.
|
||||
const summarizeFallbackMaxWords = 1000
|
||||
|
||||
// summarizeFallbackMaxPerRun is the per-run cap used when SummarizeConfig
|
||||
// is nil.
|
||||
const summarizeFallbackMaxPerRun = 10
|
||||
|
||||
// SummarizeConfig is the narrow per-run + per-deployment config surface
|
||||
// summarize reads at execute time. Production wires a closure over the
|
||||
// `skills.summarize.*` convars; nil falls back to package defaults.
|
||||
type SummarizeConfig interface {
|
||||
MaxPerRun(ctx context.Context) int
|
||||
MaxWords(ctx context.Context) int
|
||||
}
|
||||
|
||||
// summarizeArgs is the LLM-facing param struct.
|
||||
//
|
||||
// Why two source fields (text + file_id) with exactly-one validation:
|
||||
// the agent often produces large content via read_page / read_pdf and
|
||||
// stores it as a file_id (per the v10 byte-vs-reference principle);
|
||||
// forcing it to round-trip through a string would defeat the file_id
|
||||
// pattern. Inline `text` is the simpler path for short snippets.
|
||||
type summarizeArgs struct {
|
||||
Text string `json:"text,omitempty" description:"The text to summarise. Either 'text' OR 'file_id' is required (not both). Capped at 32KB; longer inputs truncate with truncated=true in the result."`
|
||||
FileID string `json:"file_id,omitempty" description:"Alternative to 'text': summarise the contents of a saved file (from read_page/read_pdf/file_save). Must belong to this skill."`
|
||||
MaxWords int `json:"max_words,omitempty" description:"Maximum word count for the summary. Default 200, capped at skills.summarize.max_words (default 1000)."`
|
||||
Focus string `json:"focus,omitempty" description:"Optional: what aspect to emphasise (e.g. 'security implications', 'cost analysis', 'main characters')."`
|
||||
}
|
||||
|
||||
type summarizeResult struct {
|
||||
Summary string `json:"summary"`
|
||||
WordCount int `json:"word_count"`
|
||||
ModelUsed string `json:"model_used"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
BudgetMsg string `json:"budget_message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// NewSummarize constructs the summarize tool. helper / cfg / budget /
|
||||
// fileStorage may all be nil; the handler surfaces clean errors at
|
||||
// first call.
|
||||
func NewSummarize(helper *llmmeta.Helper, cfg SummarizeConfig, budget SearchBudget, fileStorage FileStorage) tool.Tool {
|
||||
return tool.NewGatedTool[summarizeArgs](
|
||||
"summarize",
|
||||
"Produce a concise summary of input text using a fast LLM. Pass either 'text' or 'file_id' (one of them is required). Optional 'focus' steers the summary; 'max_words' caps length (default 200). Counts against per-run and 7-day cost budgets.",
|
||||
tool.Permission{
|
||||
AuthoringRequirement: tool.RequirementAnyone,
|
||||
OperatesOn: tool.ScopeCaller,
|
||||
SafeForShare: true,
|
||||
Categories: []string{"llm-meta", "cost-bearing"},
|
||||
},
|
||||
func(ctx context.Context, inv tool.Invocation, args summarizeArgs) (string, error) {
|
||||
if helper == nil {
|
||||
return "", fmt.Errorf("summarize: not configured")
|
||||
}
|
||||
text, truncated, err := loadSummarizeInput(ctx, inv, args, fileStorage)
|
||||
if err != nil {
|
||||
return marshalSummarizeResult(summarizeResult{Error: err.Error()}), nil
|
||||
}
|
||||
|
||||
// Per-run budget BEFORE the LLM call so a runaway loop is
|
||||
// bounded.
|
||||
if budget == nil {
|
||||
maxPerRun := summarizeFallbackMaxPerRun
|
||||
if cfg != nil {
|
||||
maxPerRun = cfg.MaxPerRun(ctx)
|
||||
}
|
||||
budget = NewInMemorySearchBudget(map[string]int{
|
||||
"summarize": maxPerRun,
|
||||
})
|
||||
}
|
||||
count, max, exceeded := budget.CheckAndIncrement(ctx, inv.RunID, "summarize")
|
||||
if exceeded {
|
||||
return marshalSummarizeResult(summarizeResult{
|
||||
Error: "summarize_budget_exceeded",
|
||||
BudgetMsg: fmt.Sprintf("per-run summarize budget exceeded (%d/%d). Work with the summaries you already have, or ask an admin to raise skills.summarize.max_per_run.", count, max),
|
||||
}), nil
|
||||
}
|
||||
|
||||
maxWords := args.MaxWords
|
||||
if maxWords <= 0 {
|
||||
maxWords = summarizeDefaultMaxWords
|
||||
}
|
||||
cap := summarizeFallbackMaxWords
|
||||
if cfg != nil {
|
||||
cap = cfg.MaxWords(ctx)
|
||||
}
|
||||
if maxWords > cap {
|
||||
maxWords = cap
|
||||
}
|
||||
|
||||
systemPrompt := "You produce concise, accurate summaries. Honor the requested word count. Do NOT invent facts."
|
||||
userPrompt := buildSummarizePrompt(text, maxWords, args.Focus)
|
||||
|
||||
res, callErr := helper.Call(ctx, llmmeta.CallSpec{
|
||||
Tier: "fast",
|
||||
SystemPrompt: systemPrompt,
|
||||
UserPrompt: userPrompt,
|
||||
MaxOutputTokens: maxWords * 8, // ~8 tokens per word upper bound
|
||||
ResponseFormat: "text",
|
||||
ToolName: "summarize",
|
||||
RunID: inv.RunID,
|
||||
SkillID: inv.SkillID,
|
||||
CallerID: inv.CallerID,
|
||||
})
|
||||
if callErr != nil {
|
||||
return "", callErr
|
||||
}
|
||||
if !res.Success || res.Text == "" {
|
||||
kind := res.ErrorKind
|
||||
if kind == "" {
|
||||
kind = "llm_unavailable"
|
||||
}
|
||||
return marshalSummarizeResult(summarizeResult{Error: kind}), nil
|
||||
}
|
||||
summary := strings.TrimSpace(res.Text)
|
||||
return marshalSummarizeResult(summarizeResult{
|
||||
Summary: summary,
|
||||
WordCount: countWords(summary),
|
||||
ModelUsed: res.ModelUsed,
|
||||
Truncated: truncated,
|
||||
}), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// loadSummarizeInput resolves the input text from either args.Text or
|
||||
// args.FileID. Exactly one MUST be supplied; both empty AND both
|
||||
// populated are rejected.
|
||||
func loadSummarizeInput(ctx context.Context, inv tool.Invocation, args summarizeArgs, fileStorage FileStorage) (string, bool, error) {
|
||||
hasText := strings.TrimSpace(args.Text) != ""
|
||||
hasFile := strings.TrimSpace(args.FileID) != ""
|
||||
if hasText == hasFile {
|
||||
// Both empty OR both populated.
|
||||
if !hasText {
|
||||
return "", false, fmt.Errorf("summarize: one of 'text' or 'file_id' is required")
|
||||
}
|
||||
return "", false, fmt.Errorf("summarize: 'text' and 'file_id' are mutually exclusive — pass one")
|
||||
}
|
||||
if hasText {
|
||||
return capInput(args.Text)
|
||||
}
|
||||
if fileStorage == nil {
|
||||
return "", false, fmt.Errorf("summarize: file_id input requires file storage to be configured")
|
||||
}
|
||||
meta, content, err := fileStorage.FileGet(ctx, args.FileID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrFileNotFound) {
|
||||
return "", false, fmt.Errorf("summarize: file_id not found")
|
||||
}
|
||||
return "", false, fmt.Errorf("summarize: file fetch: %w", err)
|
||||
}
|
||||
if meta.SkillID != inv.SkillID {
|
||||
return "", false, fmt.Errorf("summarize: file does not belong to this skill")
|
||||
}
|
||||
if err := ValidateScope(inv, meta.Scope, inv.CallerIsAdmin); err != nil {
|
||||
return "", false, fmt.Errorf("summarize: %w", err)
|
||||
}
|
||||
return capInput(string(content))
|
||||
}
|
||||
|
||||
// capInput truncates input to the hard byte cap, returning the
|
||||
// (possibly truncated) text and a flag indicating truncation occurred.
|
||||
func capInput(text string) (string, bool, error) {
|
||||
if len(text) <= summarizeMaxInputBytes {
|
||||
return text, false, nil
|
||||
}
|
||||
return truncateUTF8(text, summarizeMaxInputBytes), true, nil
|
||||
}
|
||||
|
||||
// buildSummarizePrompt composes the user message handed to the LLM.
|
||||
func buildSummarizePrompt(text string, maxWords int, focus string) string {
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "Summarise the following text in at most %d words.", maxWords)
|
||||
if focus = strings.TrimSpace(focus); focus != "" {
|
||||
fmt.Fprintf(&sb, " Emphasise: %s.", focus)
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(text)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// countWords returns a rough word count via whitespace splitting.
|
||||
// Good enough for the response's word_count column; the agent might
|
||||
// see slight discrepancies vs the LLM's internal counter, which is
|
||||
// acceptable.
|
||||
func countWords(text string) int {
|
||||
return len(strings.Fields(text))
|
||||
}
|
||||
|
||||
// marshalSummarizeResult serialises a summarizeResult to JSON.
|
||||
func marshalSummarizeResult(r summarizeResult) string {
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"error":"marshal_failed: %v"}`, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Package tools — v11 think.
|
||||
//
|
||||
// Pure prompt-engineering tool: the agent's "thought" is recorded
|
||||
// to skill_run_logs (via the audit hook the gated wrapper applies
|
||||
// transparently) but produces no side effect. The literature on
|
||||
// agent design notes that giving an agent an explicit `think` tool
|
||||
// keeps it on plan better than giving it nothing — without one,
|
||||
// agents tend to either skip planning OR babble into the final
|
||||
// output. With one, planning lands in tool calls and the final
|
||||
// output stays clean.
|
||||
//
|
||||
// V11 deliberately rejects empty thoughts. An agent that learns
|
||||
// "calling think with empty args is free" will spam it; a
|
||||
// rejection forces the call to actually carry reasoning.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
type thinkParams struct {
|
||||
Thought string `json:"thought" description:"Your reasoning. May be a plan, a working hypothesis, an analysis of a tool result, or anything else you'd note in a private scratchpad. Empty input is rejected — make this load-bearing."`
|
||||
}
|
||||
|
||||
// thinkResponse is intentionally minimal. The agent doesn't need
|
||||
// machine-readable output; the value is the audit trail + the
|
||||
// implicit "now you've planned, what's next" prompting the call
|
||||
// gives the agent loop.
|
||||
type thinkResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// NewThink constructs the v11 think tool. No deps — the audit
|
||||
// hook wrapper handles persistence transparently.
|
||||
func NewThink() tool.Tool {
|
||||
return tool.NewGatedTool[thinkParams](
|
||||
"think",
|
||||
"Record a thought / plan / working hypothesis. The thought is logged to the run trace but does NOT affect any external state. Use to slow down before a tricky tool call, sketch a multi-step plan, or summarise findings before continuing. Empty thoughts are rejected.",
|
||||
tool.Permission{
|
||||
AuthoringRequirement: tool.RequirementAnyone,
|
||||
OperatesOn: tool.ScopeGlobal,
|
||||
SafeForShare: true,
|
||||
Categories: []string{"utility"},
|
||||
},
|
||||
func(_ context.Context, _ tool.Invocation, p thinkParams) (string, error) {
|
||||
if strings.TrimSpace(p.Thought) == "" {
|
||||
// Returns ok:false in a structured envelope rather
|
||||
// than an error so the agent loop continues with a
|
||||
// recoverable signal.
|
||||
return `{"ok":false,"error":"empty_thought"}`, nil
|
||||
}
|
||||
// Successful think emits a flat JSON. The audit hook
|
||||
// (auto-injected by NewGatedTool) writes the args + result
|
||||
// pair so the trace UI shows the thought verbatim.
|
||||
return `{"ok":true}`, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Note: returning a hand-rolled JSON literal instead of a marshaller
|
||||
// keeps think the cheapest possible tool — no heap allocation, no
|
||||
// json.Marshal call, no goroutine-local buffer churn. The two output
|
||||
// shapes are static. If a future field is added to thinkResponse,
|
||||
// switch back to json.Marshal — but until then, the literal is the
|
||||
// idiom that matches the tool's "do nothing" intent.
|
||||
var _ = thinkResponse{} // declared so vet doesn't flag the unused struct
|
||||
@@ -0,0 +1,96 @@
|
||||
// Package tools is executus's library of generic, host-agnostic agent tools.
|
||||
//
|
||||
// A host registers the tools it wants against a tool.Registry, then runs an
|
||||
// agent whose RunnableAgent.LowLevelTools name them. Tools split two ways:
|
||||
//
|
||||
// - Always-available, zero-configuration tools register via Register (think,
|
||||
// now, cite) — all nil-safe, so a light host (gadfly) calls Register and is
|
||||
// immediately useful.
|
||||
// - Backed tools take a nil-safe Deps describing their host backend and
|
||||
// register via grouped registrars (RegisterMeta, and RegisterWeb/Store/…
|
||||
// as they land). Each Deps ships sensible defaults so "some setup" is small.
|
||||
//
|
||||
// A host adds its own domain tools against the SAME registry.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/llmmeta"
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
// Register adds the always-available, zero-configuration generic tools:
|
||||
//
|
||||
// - think — record a thought to the run trace (no external effect)
|
||||
// - now — current time (UTC unless a CurrentTimeProvider is wired)
|
||||
// - cite — record a source citation (inert unless a CitationStorage is wired)
|
||||
//
|
||||
// All are nil-safe. Returns the first registration error.
|
||||
func Register(reg tool.Registry) error {
|
||||
return registerAll(reg,
|
||||
NewThink(),
|
||||
NewNow(nil),
|
||||
NewCite(nil),
|
||||
)
|
||||
}
|
||||
|
||||
// MetaDeps wires the LLM-backed meta tools (classify, extract_entities,
|
||||
// summarize). Helper is required. Budget defaults to an in-memory per-run cap;
|
||||
// Files is optional (summarize's file_id input is inert without it); MaxPerRun
|
||||
// and MaxWords default when non-positive.
|
||||
type MetaDeps struct {
|
||||
Helper *llmmeta.Helper
|
||||
Budget SearchBudget
|
||||
Files FileStorage
|
||||
MaxPerRun int // per-run cap for each meta tool; default 10
|
||||
MaxWords int // summarize length cap; default 200
|
||||
}
|
||||
|
||||
// RegisterMeta adds classify, extract_entities, and summarize. It requires a
|
||||
// configured llmmeta.Helper (the fast-tier meta-LLM caller); everything else
|
||||
// defaults.
|
||||
func RegisterMeta(reg tool.Registry, d MetaDeps) error {
|
||||
if d.Helper == nil {
|
||||
return errors.New("tools: MetaDeps.Helper is required for the meta tools")
|
||||
}
|
||||
if d.MaxPerRun <= 0 {
|
||||
d.MaxPerRun = 10
|
||||
}
|
||||
if d.MaxWords <= 0 {
|
||||
d.MaxWords = 200
|
||||
}
|
||||
if d.Budget == nil {
|
||||
// Build the default budget WITH the configured per-run cap so
|
||||
// MetaDeps.MaxPerRun is honored — an empty caps map would fall back to
|
||||
// the budget's hardcoded default and silently ignore MaxPerRun.
|
||||
d.Budget = NewInMemorySearchBudget(map[string]int{
|
||||
"classify": d.MaxPerRun,
|
||||
"extract_entities": d.MaxPerRun,
|
||||
"summarize": d.MaxPerRun,
|
||||
})
|
||||
}
|
||||
cfg := fixedMetaConfig{maxPerRun: d.MaxPerRun, maxWords: d.MaxWords}
|
||||
return registerAll(reg,
|
||||
NewClassify(d.Helper, cfg, d.Budget),
|
||||
NewExtractEntities(d.Helper, cfg, d.Budget),
|
||||
NewSummarize(d.Helper, cfg, d.Budget, d.Files),
|
||||
)
|
||||
}
|
||||
|
||||
func registerAll(reg tool.Registry, ts ...tool.Tool) error {
|
||||
for _, t := range ts {
|
||||
if err := reg.Register(t); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fixedMetaConfig satisfies ClassifyConfig / ExtractEntitiesConfig /
|
||||
// SummarizeConfig with static caps read from MetaDeps.
|
||||
type fixedMetaConfig struct{ maxPerRun, maxWords int }
|
||||
|
||||
func (c fixedMetaConfig) MaxPerRun(context.Context) int { return c.maxPerRun }
|
||||
func (c fixedMetaConfig) MaxWords(context.Context) int { return c.maxWords }
|
||||
@@ -0,0 +1,18 @@
|
||||
package tools
|
||||
|
||||
import "unicode/utf8"
|
||||
|
||||
// truncateUTF8 returns s truncated to at most maxBytes, backing off to the last
|
||||
// complete UTF-8 rune boundary so a multibyte rune (CJK, emoji, …) is never
|
||||
// split — a byte-boundary cut would hand the LLM invalid UTF-8 / replacement
|
||||
// chars. Used by the meta tools' input caps.
|
||||
func truncateUTF8(s string, maxBytes int) string {
|
||||
if len(s) <= maxBytes {
|
||||
return s
|
||||
}
|
||||
s = s[:maxBytes]
|
||||
for len(s) > 0 && !utf8.ValidString(s) {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user