Files
Algis DumbrisandClaude Opus 4.6 ff5d0c49f4 feat(018): dynamic agent spawning — primitives + doc-gardener demo
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>
2026-04-14 15:29:21 +03:00

163 lines
4.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}