Ships the MVP slice of spec 018 (dynamic agent spawning):
- 5 new SQLite migrations (021-025): goals + goal_tasks + agent_proposals
+ reputation_evidence + secrets + harness_runs.task_id. The legacy
`tasks` table (channel auctions) and `agent_trust` table (reactions
workflow) are left untouched — the new schema coexists.
- 4 new internal packages, fully tested:
- internal/goals: Goal struct + store + service, slug collision dedup,
backing-channel auto-create via ChannelCreator adapter
- internal/goaltasks: goal_tasks table with denormalized 16 KB
ancestry snapshots, single-statement optimistic-lock atomic claim,
recursive-CTE cost rollup, state machine, per-billing-code rollup
- internal/secrets: NaCl-secretbox encrypted blobs, user/agent/task
scope precedence, sanitized env injection, master-key bootstrap
- internal/trust additions: ConfigHash (deterministic SHA-256 of
model + prompt + tools + skills + mcp + subagents, sorted),
DelegationCap (tier + tool-scope + budget + depth enforcement),
append-only Ledger with exponential time-decay rolling score and
70%-of-parent child seeding. Existing trust package unchanged.
- Critical invariants under test:
- 50-goroutine concurrent claim race → exactly one winner per round
- ConfigHash stable under shuffled array inputs, sensitive to
capability changes
- DelegationCap full tier × tool-scope matrix
- Ledger time-decay + parent seed at 70 % ± 1 %
- Secret name sanitization, scope precedence, plaintext never
returned via MCP-equivalent paths
- internal/agents/types.go extended with dynamic-spawning columns
(config_hash, parent_agent_id, spawn_depth, system_prompt,
autonomy_tier, tool_scope_json, quarantined_at). Existing tests
still pass.
- cmd/docgardener: self-contained demo binary driving the end-to-end
flow. `docgardener run` creates a goal, builds a task tree with
denormalized ancestry, spawns 3 specialists (each going through
real delegation-cap validation and config-hash computation and
70 %-of-parent reputation seeding), claims tasks atomically, runs
them through the state machine, records reputation evidence.
`docgardener report` queries all of that back out and renders a
rich dark-mode HTML report (header, spend metrics, task tree,
spawned-agent cards with reputation bars, cost breakdown, artifacts,
timeline).
- examples/doc-gardener: start.sh / run_task.sh / report.sh / stop.sh
mirroring the cold-topic-explainer pattern. Launches an isolated
synapbus instance on port 18089, drives the demo, renders
report.html, cleans up. Full README documenting what's real vs
deferred, plus examples/README.md listing both examples.
- specs/018: tasks.md updated with MVP completion status; legacy tasks
naming collision noted.
Deferred (marked explicitly in example README):
- Real LLM-driven coordinator (needs MCP tool wiring + prompt
iteration)
- Real subprocess runs (needs reactor integration with task_id on
ExecRequest)
- Full MCP tool surface (contracts are written at
specs/018-dynamic-agent-spawning/contracts/mcp-tools.md)
- Svelte /goals UI (REST endpoints remain a follow-up)
- Full budget race + quarantine auto-trigger wiring
- Full resource-request → secrets fulfill reaction-workflow path
Cross-compiles clean for linux/amd64 and darwin/arm64 with no CGO
(SC-010). All new package tests pass (SC-004, SC-005, SC-007).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
163 lines
4.8 KiB
Go
163 lines
4.8 KiB
Go
package trust
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"time"
|
||
)
|
||
|
||
// defaultHalfLifeDays is used when a caller passes 0 to RollingScore
|
||
// or SeedFromParent.
|
||
const defaultHalfLifeDays = 30.0
|
||
|
||
// neutralScore is the rolling score returned when no evidence exists for a
|
||
// (config_hash, task_domain) pair. 0.5 signals "no information yet".
|
||
const neutralScore = 0.5
|
||
|
||
// seedFromParentFraction is the fraction of the parent's rolling score that
|
||
// gets seeded onto a fresh child config_hash when SeedFromParent is called.
|
||
const seedFromParentFraction = 0.7
|
||
|
||
// Evidence is a single append-only entry in the reputation ledger.
|
||
//
|
||
// score_delta is unbounded (positive or negative); the rolling score is
|
||
// computed at read time by summing decayed deltas and clamping to [0, 1].
|
||
type Evidence struct {
|
||
ID int64
|
||
ConfigHash string
|
||
OwnerUserID int64
|
||
TaskDomain string
|
||
ScoreDelta float64
|
||
Weight float64
|
||
EvidenceRef string
|
||
CreatedAt time.Time
|
||
}
|
||
|
||
// Ledger is the new dynamic-spawning reputation store. It is fully separate
|
||
// from the legacy *Service / Store types: this one is keyed by config_hash
|
||
// and is append-only, while the legacy store is keyed by agent name and
|
||
// performs in-place upserts.
|
||
type Ledger struct {
|
||
db *sql.DB
|
||
}
|
||
|
||
// NewLedger constructs a Ledger backed by the given *sql.DB. The caller is
|
||
// responsible for migration ordering — migration 023 must already be applied.
|
||
func NewLedger(db *sql.DB) *Ledger {
|
||
return &Ledger{db: db}
|
||
}
|
||
|
||
// Append writes one evidence row and returns its row id.
|
||
//
|
||
// The CreatedAt field, if zero, defaults to the database's CURRENT_TIMESTAMP.
|
||
// TaskDomain defaults to "default" and Weight defaults to 1.0.
|
||
func (l *Ledger) Append(ctx context.Context, ev Evidence) (int64, error) {
|
||
if ev.TaskDomain == "" {
|
||
ev.TaskDomain = "default"
|
||
}
|
||
if ev.Weight == 0 {
|
||
ev.Weight = 1.0
|
||
}
|
||
|
||
var (
|
||
res sql.Result
|
||
err error
|
||
)
|
||
if ev.CreatedAt.IsZero() {
|
||
res, err = l.db.ExecContext(ctx,
|
||
`INSERT INTO reputation_evidence
|
||
(config_hash, owner_user_id, task_domain, score_delta, evidence_ref, weight)
|
||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||
ev.ConfigHash, ev.OwnerUserID, ev.TaskDomain, ev.ScoreDelta, ev.EvidenceRef, ev.Weight,
|
||
)
|
||
} else {
|
||
res, err = l.db.ExecContext(ctx,
|
||
`INSERT INTO reputation_evidence
|
||
(config_hash, owner_user_id, task_domain, score_delta, evidence_ref, weight, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||
ev.ConfigHash, ev.OwnerUserID, ev.TaskDomain, ev.ScoreDelta, ev.EvidenceRef, ev.Weight,
|
||
ev.CreatedAt.UTC().Format("2006-01-02 15:04:05"),
|
||
)
|
||
}
|
||
if err != nil {
|
||
return 0, fmt.Errorf("append evidence: %w", err)
|
||
}
|
||
id, err := res.LastInsertId()
|
||
if err != nil {
|
||
return 0, fmt.Errorf("get last insert id: %w", err)
|
||
}
|
||
return id, nil
|
||
}
|
||
|
||
// RollingScore returns the time-decayed rolling reputation score for a
|
||
// (config_hash, task_domain) pair, clamped to [0.0, 1.0], plus the count of
|
||
// evidence rows considered.
|
||
//
|
||
// halfLifeDays controls how quickly old evidence loses weight. Pass 0 for the
|
||
// 30-day default.
|
||
//
|
||
// When no evidence exists, the result is (neutralScore, 0, nil).
|
||
func (l *Ledger) RollingScore(ctx context.Context, configHash, taskDomain string, halfLifeDays float64) (float64, int, error) {
|
||
if halfLifeDays <= 0 {
|
||
halfLifeDays = defaultHalfLifeDays
|
||
}
|
||
if taskDomain == "" {
|
||
taskDomain = "default"
|
||
}
|
||
|
||
const q = `
|
||
SELECT COALESCE(SUM(score_delta * weight *
|
||
exp(-0.6931471805599453 * (julianday('now') - julianday(created_at)) / ?)), 0.5) AS score,
|
||
COUNT(*) AS cnt
|
||
FROM reputation_evidence
|
||
WHERE config_hash = ? AND task_domain = ?`
|
||
|
||
var (
|
||
score float64
|
||
cnt int
|
||
)
|
||
if err := l.db.QueryRowContext(ctx, q, halfLifeDays, configHash, taskDomain).Scan(&score, &cnt); err != nil {
|
||
return 0, 0, fmt.Errorf("rolling score query: %w", err)
|
||
}
|
||
|
||
if cnt == 0 {
|
||
return neutralScore, 0, nil
|
||
}
|
||
|
||
if score < 0.0 {
|
||
score = 0.0
|
||
}
|
||
if score > 1.0 {
|
||
score = 1.0
|
||
}
|
||
return score, cnt, nil
|
||
}
|
||
|
||
// SeedFromParent reads the parent config's current rolling score and writes
|
||
// a single seed evidence row for the child config at
|
||
// seedFromParentFraction × parent_score.
|
||
//
|
||
// Used when a new agent config is spawned: rather than starting at the neutral
|
||
// 0.5, the child inherits 70% of the parent's reputation as a starting prior.
|
||
func (l *Ledger) SeedFromParent(ctx context.Context, parentHash, childHash string, ownerID int64, taskDomain string, halfLifeDays float64) error {
|
||
parentScore, _, err := l.RollingScore(ctx, parentHash, taskDomain, halfLifeDays)
|
||
if err != nil {
|
||
return fmt.Errorf("read parent score: %w", err)
|
||
}
|
||
|
||
seedDelta := seedFromParentFraction * parentScore
|
||
_, err = l.Append(ctx, Evidence{
|
||
ConfigHash: childHash,
|
||
OwnerUserID: ownerID,
|
||
TaskDomain: taskDomain,
|
||
ScoreDelta: seedDelta,
|
||
Weight: 1.0,
|
||
EvidenceRef: fmt.Sprintf("seed_from_parent:%s", parentHash),
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("write seed evidence: %w", err)
|
||
}
|
||
return nil
|
||
}
|