Compare commits

...
Author SHA1 Message Date
BaaRai 60728092e1 fix(db): stop billing cache writes twice in profile cost
Norma normalizes InputTokens to input + cache read + cache write, so both cache buckets are subsets of it. The fresh share billed at the input price is now input minus reads minus writes, still clamped at zero for gateways that report disjoint buckets.
2026-11-15 18:17:33 +01:00
BaaRai ff599c65d3 Merge pull request #243 from BaaRai/fix/stream-usage-echo
fix(llmrec,sidequestion): fold echoed stream usage with MergeEcho
2026-11-15 18:14:20 +01:00
BaaRai ce096cdba1 fix(llmrec,sidequestion): fold echoed stream usage with MergeEcho
Since Norma v0.4.7 message_delta echoes the input side reported by message_start. The recorder and side answers added both events, counting input and cache twice in llm_usage and side-answer usage. message_start is still added; message_delta is now folded with llm.Usage.MergeEcho, as llm.Accumulator does.
2026-11-15 17:51:53 +01:00
BaaRai 50f6d07640 Merge pull request #241 from BaaRai/fix/task-llm-usage
fix(server): sum task LLM usage across retries and failover
2026-11-15 17:48:11 +01:00
BaaRai e6ab0f34a7 fix(server): sum task LLM usage across retries and failover
The logical call now returns the usage of every physical attempt (atomic and stream), keeps the usage of failed uncommitted stream attempts without delivering their events, normalizes per-attempt echoes with MergeEcho, stops with ctx.Err() when cancelled during backoff, and treats signed/redacted thinking and tool-use signatures as committing output. The llm_usage ledger stays at one row per physical call.
2026-11-15 16:14:35 +01:00
BaaRai 5ed28cde9f Merge pull request #240 from BaaRai/fix/session-resume
fix(agent): stop on transcript restore failures and keep session identity
2026-11-15 16:07:37 +01:00
BaaRai 773fa9183a fix(agent): stop on transcript restore failures and keep session identity without persistence
Resume errors from a corrupt or unreadable transcript are returned as "restore Norma session %q: %w" before any model or tool call instead of being dropped; a missing transcript still starts a fresh session. The session id is set and put on the context regardless of the transcript store, so the provider and usage metering see the same identity.
2026-11-15 15:32:07 +01:00
BaaRai 1cbab7cc39 Merge pull request #239 from BaaRai/chore/next-16-4-6
chore(web): upgrade next to 16.4.6
2026-11-15 15:23:50 +01:00
BaaRai bc75b5dc43 chore(web): upgrade next to 16.4.6
Closes the critical Next.js advisory reported by npm audit for next <=16.4.0. Only next and its own tree move in the lock (@next/*, @swc/helpers, sharp and its @img packages, postcss and nanoid); react and react-dom are unchanged.
2026-11-15 14:35:21 +01:00
BaaRai cddcd98f3b Merge pull request #238 from BaaRai/chore/npm-prod-deps
chore(web): move the shadcn CLI to devDependencies and drop @antv/graphin
2026-11-15 14:18:15 +01:00
BaaRai d42988b6a8 chore(web): move the shadcn CLI to devDependencies and drop @antv/graphin
shadcn is only used at build time (globals.css imports shadcn/tailwind.css, and the CLI generates components); no web/src file imports it at runtime. @antv/graphin is not imported anywhere. The lock only marks the shadcn dependency tree as dev and removes @antv/graphin; no version changes.
2026-11-15 14:01:02 +01:00
BaaRai 3975e58ce0 Merge pull request #237 from BaaRai/ci/gofmt
ci: run gofmt in the go job, and format the 11 files it flags
2026-11-15 13:55:11 +01:00
BaaRai fdba29f1a9 ci: run gofmt in the go job, and format the 11 files it flags
Mechanical: gofmt -w on the 11 files gofmt -l listed on main. Besides spacing and alignment, gofmt sorted the imports inside one group in two test files and removed a trailing blank line in a third. The go job now fails, listing the files, when gofmt -l reports any tracked Go file.
2026-11-15 13:38:35 +01:00
BaaRai a59d430175 Merge pull request #236 from BaaRai/perf/task-metrics
perf(db): per-task metrics for the task detail and an index-served latest activity
2026-11-15 08:40:39 +01:00
BaaRai c2d1f557c3 perf(db): per-task metrics for the task detail and an index-served latest activity
getTask computed the list metrics of every task to read one; it now calls TaskListMetrics(expID), which runs the same laterals as TaskListMetricsAll with a WHERE on the exploration. listTasks keeps the global query. The latest_activity lateral ordered by its EXTRACT(EPOCH) output alias, so PostgreSQL read and sorted every activity row of each task; it now orders by activity.created_at and idx_act_latest serves the LIMIT 1. Both queries share one SQL constant.
2026-11-15 08:20:08 +01:00
BaaRai e636408f66 Merge pull request #235 from BaaRai/docs/upstream-changelog
docs: credit the upstream and complete the unreleased changelog
2026-11-15 02:01:55 +01:00
BaaRai fcbd87445e docs: credit the upstream and complete the unreleased changelog 2026-11-15 01:54:50 +01:00
BaaRai 59f768f1ef Merge pull request #234 from BaaRai/feat/proxy-pace-bash
feat(traffic): pace Bash requests at the capture proxy
2026-11-15 01:41:56 +01:00
33 changed files with 1951 additions and 380 deletions
+4
View File
@@ -29,6 +29,10 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: gofmt
run: |
files=$(gofmt -l $(git ls-files '*.go'))
if [ -n "$files" ]; then echo "gofmt required:"; echo "$files"; exit 1; fi
- run: go build ./...
- run: go vet ./...
# Skipped tests, tracked in issue #9:
+31 -3
View File
@@ -6,12 +6,40 @@ All notable changes to this project are documented in this file. The format is b
### Added
- **Noise profile at runtime**: System settings has a **Target noise** card. It sets the profile (`off`, `quiet`, `balanced`, `aggressive`) through the `noise_profile` setting, which takes precedence over `ARTEX_NOISE_PROFILE`. The change applies to the next WebFetch, http custom tool and enrich request, and to the next agent prompt, without a restart. The card also shows the per-host counters, which `GET /api/settings/noise` returns.
- **Bash requests paced through the capture proxy**: with traffic capture on, the Bash proxy URL carries a marker userinfo. The recording proxy paces each request of a marked connection by the noise profile's per-host rate and jitter, and counts it, before it goes upstream. This applies inside CONNECT tunnels too. WebFetch, the http custom tools and enrich keep the unmarked URL and are not paced twice. The proxy now drops `Proxy-Authorization` from every request it forwards or records. This is best effort: only clients that honour the proxy are covered, with capture on.
- **Target noise governor**: one shared egress governor paces ARTEX's own HTTP requests (WebFetch, the http custom tools and the enrich probe) against a noise profile (`off` by default, `quiet`, `balanced`, `aggressive`): a per-host token bucket with jitter, per-host and global in-flight caps, 429/503 backoff, GET deduplication with a short cache, and a configurable target User-Agent (`ARTEX_TARGET_USER_AGENT`). Off is a strict passthrough (#205).
- **Low-and-slow prompt block**: when a noise profile is set, the planner, worker, main agent and chat prompts carry the per-host limits of the profile and the exact rate or delay flags for nmap, nuclei, httpx, katana, ffuf, dalfox and sqlmap; with the profile off the prompts are unchanged (#212).
- **Noise profile at runtime**: System settings has a **Target noise** card. It sets the profile (`off`, `quiet`, `balanced`, `aggressive`) through the `noise_profile` setting, which takes precedence over `ARTEX_NOISE_PROFILE`. The change applies to the next WebFetch, http custom tool and enrich request, and to the next agent prompt, without a restart. The card also shows the per-host counters, which `GET /api/settings/noise` returns (#233).
- **Bash requests paced through the capture proxy**: with traffic capture on, the Bash proxy URL carries a marker userinfo. The recording proxy paces each request of a marked connection by the noise profile's per-host rate and jitter, and counts it, before it goes upstream. This applies inside CONNECT tunnels too. WebFetch, the http custom tools and enrich keep the unmarked URL and are not paced twice. The proxy now drops `Proxy-Authorization` from every request it forwards or records. This is best effort: only clients that honour the proxy are covered, with capture on (#234).
- **Hierarchical budgets**, off by default: a task budget and a per-intent budget take the minimum of their own budget and the parent's remaining budget above the wrap-up reserve, planner calls count inside the task, and reaching the reserve forces a wrap-up (#206).
- **Coverage ledger**: the worker records what it examined, ruled out or left open for each surface and risk class with `record_coverage`; gaps, unexamined classes and completeness appear in the planner context, `GET /api/tasks/{id}/coverage`, the coverage card and the Markdown report (#207).
- **Closed-question judge**: the LLM fallback judge answers eight fixed questions that the code owns and the code takes the decision; every failure now leads to ask instead of allow, and the previous behaviour stays available as `llm_judge_mode = freeform` (#208).
- **argv interception rules**: a new `match_type = argv` matches the parsed prefix of a Bash command exactly, and a pending Bash approval can create such a rule in one click; a code-owned denylist refuses programs where a prefix would allow arbitrary code (#210, #211).
- **Scanner output ingestion**: `ingest_scan_output` parses nmap XML, nuclei JSONL, httpx JSONL and ffuf JSON, and writes assets and facts deterministically, so the model never reads the raw output (#214, #215).
- **bbot ingestion**: a typed parser splits bbot JSONL events by `scope_distance`, only in-scope events go through `insert_assets`, and `ingest_bbot` reads the file line by line with a 64 KiB cap (#199, #200).
- **Tool RAG**, off by default: when the bound tool list of an agent is large, only the subset relevant to the current intent is injected, as a projection that never adds or edits a tool (`ARTEX_TOOL_RAG`) (#204).
- **Fact provenance**: `record_fact` takes a closed `source` enum (default `inference`) and every fact carries the server-set `origin`, both shown in `recent_facts`, `list_facts` and `expand_digest` (#194).
- **Per-message nonce envelope**: agent- and target-derived fields reach the planner and worker user messages inside a code-owned data envelope with a random nonce per message; operator input stays outside it (#195).
- **Intercept risk score and batch approval**: a deterministic risk score (low, medium, high) is shown on each pending approval, and up to 100 pending decisions can be settled in one call, each through the existing single-decision path (#232).
- **`ask_operator` target warning**: a request whose question names a host or URL outside the goal targets is tagged as containing target-derived text (#197).
- **Opt-in update banner**: `ARTEX_UPDATE_CHECK` makes the backend check the latest GitHub release at most once per 24 hours and the UI show a banner; off by default (#202).
- **Release provenance**: release archives carry GitHub build provenance attestations and a CycloneDX SBOM (`gh attestation verify <zip> --repo BaaRai/ARTEX`); release notes are the matching `CHANGELOG.md` section (#219, #201).
### Changed
- **Untrusted-output boundary is now on by default**: Norma frames tool results for the worker, planner, chat and main agent unless `ARTEX_UNTRUSTED_BOUNDARY` is set to `0`, `false`, `off` or `no`. It was opt-in via `ARTEX_UNTRUSTED_BOUNDARY=1` in 0.6.0 (#166).
- **Untrusted-output boundary is now on by default**: Norma frames tool results for the worker, planner, chat and main agent unless `ARTEX_UNTRUSTED_BOUNDARY` is set to `0`, `false`, `off` or `no`. It was opt-in via `ARTEX_UNTRUSTED_BOUNDARY=1` in 0.6.0 (#166); the default flips here (#203).
- **Versioned migrations**: the `db` package applies numbered `db/migrations/NNNN_slug.sql` files once, in order, under the schema advisory lock, and records a SHA-256 checksum in `schema_migrations`; missing, renamed, changed or out-of-order migrations fail closed (#226).
- **Coverage graph performance**: the coverage graph batches its connector lookups (three queries instead of one per root and per company) and adds the partial index `idx_ts_icp`; on a task of about 3,000 assets the p50 dropped from 126 ms to 27 ms and the queries per call from 221 to 3 (#230).
### Fixed
- **Auto-scope hygiene**: auto `task_scope` rows count for coverage and untested assets only under a declared root, and direct coverage counts anchored assets through fact anchors only; the scope is described as "coverage scope, not enforced" (#196).
- **Enrich HTTP probe**: it now dials through `intercept.PinnedDialer`, so the target gate applies and a probe without a target scope is refused (#198).
- **Agent hook data races**: the eight package-level hooks are synchronised, so a second wire-up no longer races tasks that already read them (#227).
### Security
- **Secrets at rest**: the credential-bearing columns and settings (LLM API keys and proxy, task credentials, MCP server env, notification channel config, search and proxy settings), the exec spec of custom tools and the evidence snapshot heads are encrypted in PostgreSQL with AES-256-GCM (`enc:v1:`) using `ARTEX_SECRET_KEY`, else `keys/secret.key`; existing plaintext rows are backfilled once at startup, and a missing or wrong key while encrypted data exists refuses to start. This protects database dumps and backups, not a running instance (#213, #216, #217, #218).
- **URL credentials are dropped before storage**: scan and bbot ingestion, the asset store and archive restore strip URL userinfo, so `https://user:pass@host/app` is stored and matched as `https://host/app` (#222, #223, #224).
## [0.6.0] - 2026-11-12
+8
View File
@@ -6,6 +6,8 @@
**Autonomous penetration testing that keeps its receipts.**
(upstream: [Autumn-27/ARTEX](https://github.com/Autumn-27), AGPL-3.0)
Self-hosted multi-agent offensive security. A planner explores, workers run real tools,
and every finding stays bound to the assets and HTTP traffic that prove it.
@@ -693,6 +695,12 @@ Operational security properties:
---
## About this fork
This repository is the English translation and continuation of ARTEX, originally by [Autumn-27](https://github.com/Autumn-27). The upstream repository is no longer public, so the link points to the author profile. Changes in this fork are tracked in [CHANGELOG.md](CHANGELOG.md), under the same AGPL-3.0 license.
---
## References and prior art
- [Cairn](https://github.com/oritera/Cairn)
+8 -2
View File
@@ -169,9 +169,12 @@ func (c *ChatAgent) Chat(ctx context.Context, agentKey, sessionID, message strin
if c.guard != nil {
opts.Hooks = c.guard.Hooks()
}
// The session id is the provider-side identity (session header, usage metering)
// with or without persistence; Norma only puts it on ctx when it writes a transcript.
opts.SessionID = sessionID
ctx = transcript.WithSessionID(ctx, opts.SessionID)
if c.tx != nil { // persist raw human↔AI conversation; one accumulating file per thread
opts.Transcript = c.tx
opts.SessionID = sessionID
}
// experimental feature: when enabled, noa takes over context compression (archive centralized under <workDir>/noa/<SessionID>, persistent).
enableNoa(&opts, c.noaEnabledFn, c.workDir, "chat-"+sessionID, noaWarn("chat-"+sessionID))
@@ -180,8 +183,11 @@ func (c *ChatAgent) Chat(ctx context.Context, agentKey, sessionID, message strin
defer s.Close()
// reload prior conversation so the agent has context across turns (each Chat is
// a fresh session). First turn: no file yet → Resume loads nothing and proceeds.
// A corrupt or unreadable transcript stops the turn before any model or tool call.
if c.tx != nil {
_ = s.Resume(sessionID)
if err := resumeSession(s, opts.SessionID); err != nil {
return "", err
}
}
// re-unlock skill-gated MCPs from prior Skill() calls in the reloaded history so
// revealed tools stay callable across the fresh session.
+13 -7
View File
@@ -169,14 +169,17 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, mainSeg int, as *db.
if hooks != nil { // typed-nil guard: only set when concrete (avoids harness panic)
opts.Hooks = hooks
}
// Segment 0 keeps the legacy "exp%d-main" name so existing transcripts still
// load; each new session (seg>=1) gets its own file for a clean context. The id
// is the provider-side identity (session header, usage metering) with or without
// persistence; Norma only puts it on ctx when it writes a transcript.
opts.SessionID = fmt.Sprintf("exp%d-main", ts.ID())
if mainSeg > 0 {
opts.SessionID = fmt.Sprintf("exp%d-main-s%d", ts.ID(), mainSeg)
}
ctx = transcript.WithSessionID(ctx, opts.SessionID)
if m.tx != nil { // persist raw human↔AI conversation; one accumulating file per segment
opts.Transcript = m.tx
// Segment 0 keeps the legacy "exp%d-main" name so existing transcripts still
// load; each new session (seg>=1) gets its own file for a clean context.
opts.SessionID = fmt.Sprintf("exp%d-main", ts.ID())
if mainSeg > 0 {
opts.SessionID = fmt.Sprintf("exp%d-main-s%d", ts.ID(), mainSeg)
}
}
// experimental feature: when enabled, noa takes over context compression (archive centralized under <workDir>/noa/<SessionID>, persistent).
// the session id follows the same rule as the transcript (segment-aware), keeping archive and resume aligned.
@@ -191,8 +194,11 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, mainSeg int, as *db.
// reload the prior conversation from the transcript so the agent has context
// across turns (each Chat is a fresh session; without this it can't see earlier
// messages). First turn: no file yet → Resume loads nothing and proceeds.
// A corrupt or unreadable transcript stops the turn before any model or tool call.
if m.tx != nil {
_ = s.Resume(opts.SessionID)
if err := resumeSession(s, opts.SessionID); err != nil {
return "", err
}
}
// C2: this session is fresh each turn; re-unlock skill-gated MCPs from prior
// Skill() calls in the reloaded history so revealed tools stay callable.
+4 -1
View File
@@ -567,9 +567,12 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
if hooks != nil { // typed-nil guard: only set when concrete (avoids harness panic)
opts.Hooks = hooks
}
// The session id is the provider-side identity (session header, usage metering)
// with or without persistence; Norma only puts it on ctx when it writes a transcript.
opts.SessionID = fmt.Sprintf("exp%d-planner", ts.ID())
ctx = transcript.WithSessionID(ctx, opts.SessionID)
if p.tx != nil { // persist raw LLM conversation; one accumulating file per task's planner
opts.Transcript = p.tx
opts.SessionID = fmt.Sprintf("exp%d-planner", ts.ID())
}
// Experimental: when on, noa takes over context compression (archives persist under <workDir>/noa/<SessionID>).
noaSession := fmt.Sprintf("exp%d-planner", ts.ID())
+19
View File
@@ -0,0 +1,19 @@
package agent
import (
"fmt"
"github.com/Autumn-27/norma/agentcore"
)
// resumeSession reloads the persisted conversation of session id into s. Norma's
// transcript.Store.Load treats a missing file as a fresh session (no records, no
// error); any other failure, such as a corrupt line or an unreadable path, is
// returned so the caller stops before the first model or tool call instead of
// appending a new conversation to the damaged file.
func resumeSession(s *agentcore.Session, id string) error {
if err := s.Resume(id); err != nil {
return fmt.Errorf("restore Norma session %q: %w", id, err)
}
return nil
}
+305
View File
@@ -0,0 +1,305 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"iter"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/llm"
"github.com/Autumn-27/norma/transcript"
)
// sessionProbe is a provider that answers every call with one text turn and
// records the session id it saw on ctx plus the request messages.
type sessionProbe struct {
mu sync.Mutex
ids []string
msgs [][]llm.Message
}
func (p *sessionProbe) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
p.mu.Lock()
p.ids = append(p.ids, transcript.SessionIDFrom(ctx))
p.msgs = append(p.msgs, req.Messages)
p.mu.Unlock()
return func(yield func(llm.StreamEvent, error) bool) {
if yield(llm.StreamEvent{Type: llm.SETextDelta, Text: "done"}, nil) {
yield(llm.StreamEvent{Type: llm.SEMessageDelta, StopReason: "end_turn"}, nil)
}
}
}
func (p *sessionProbe) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
acc := llm.NewAccumulator()
for ev, err := range p.Stream(ctx, req) {
if err != nil {
return llm.Message{}, "", llm.Usage{}, err
}
acc.Add(ev)
}
return acc.Message(), acc.StopReason, acc.Usage, nil
}
func (p *sessionProbe) seen() []string {
p.mu.Lock()
defer p.mu.Unlock()
return append([]string(nil), p.ids...)
}
// jsonlFiles lists every .jsonl file under dir.
func jsonlFiles(t *testing.T, dir string) []string {
t.Helper()
var out []string
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && strings.HasSuffix(path, ".jsonl") {
out = append(out, path)
}
return nil
})
if err != nil {
t.Fatal(err)
}
return out
}
// damageTranscript makes the main transcript of id corrupt (a non-JSON line) or
// unreadable (a directory). It returns the bytes a corrupt file must keep.
func damageTranscript(t *testing.T, store *transcript.Store, id string, unreadable bool) string {
t.Helper()
path := store.MainPath(id)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if unreadable {
if err := os.Mkdir(path, 0o700); err != nil {
t.Fatal(err)
}
return ""
}
const original = "{corrupt transcript\n"
if err := os.WriteFile(path, []byte(original), 0o600); err != nil {
t.Fatal(err)
}
return original
}
// assertTranscriptUntouched checks that a damaged transcript was neither
// rewritten nor appended to.
func assertTranscriptUntouched(t *testing.T, store *transcript.Store, id, original string, unreadable bool) {
t.Helper()
path := store.MainPath(id)
if unreadable {
entries, err := os.ReadDir(path)
if err != nil || len(entries) != 0 {
t.Fatalf("unreadable transcript changed: entries=%v err=%v", entries, err)
}
return
}
b, err := os.ReadFile(path)
if err != nil || string(b) != original {
t.Fatalf("damaged transcript changed: %q err=%v", b, err)
}
}
func assertRestoreError(t *testing.T, err error, calls int) {
t.Helper()
if err == nil || !strings.Contains(err.Error(), "restore Norma session") || calls != 0 {
t.Fatalf("err=%v provider calls=%d", err, calls)
}
}
var damageCases = []struct {
name string
unreadable bool
}{{"corrupt", false}, {"unreadable-directory", true}}
func TestChatSessionIdentityWithoutTranscript(t *testing.T) {
p := &sessionProbe{}
workDir := t.TempDir()
c := NewChatAgent(p, "test-model", workDir, nil, 100000)
for range 2 {
if _, err := c.Chat(t.Context(), "assistant", "conv-stable", "hello", 1, 0, false, nil); err != nil {
t.Fatal(err)
}
}
if ids := p.seen(); len(ids) != 2 || ids[0] != "conv-stable" || ids[1] != "conv-stable" {
t.Fatalf("provider session ids: %v", ids)
}
if files := jsonlFiles(t, workDir); len(files) != 0 {
t.Fatalf("transcript written without persistence: %v", files)
}
}
func TestChatMissingTranscriptStartsFresh(t *testing.T) {
store := transcript.NewStore(t.TempDir())
p := &sessionProbe{}
c := NewChatAgent(p, "test-model", t.TempDir(), store, 100000)
if _, err := c.Chat(t.Context(), "assistant", "conv-new", "hello", 1, 0, false, nil); err != nil {
t.Fatal(err)
}
if ids := p.seen(); len(ids) != 1 || ids[0] != "conv-new" {
t.Fatalf("provider session ids: %v", ids)
}
recs, err := store.Load(store.MainPath("conv-new"))
if err != nil || len(recs) == 0 {
t.Fatalf("fresh transcript not written: %d records, err=%v", len(recs), err)
}
}
func TestChatRefusesDamagedTranscript(t *testing.T) {
for _, tc := range damageCases {
t.Run(tc.name, func(t *testing.T) {
store := transcript.NewStore(t.TempDir())
original := damageTranscript(t, store, "conv-broken", tc.unreadable)
p := &sessionProbe{}
c := NewChatAgent(p, "test-model", t.TempDir(), store, 100000)
_, err := c.Chat(t.Context(), "assistant", "conv-broken", "hello", 1, 0, false, nil)
assertRestoreError(t, err, len(p.seen()))
assertTranscriptUntouched(t, store, "conv-broken", original, tc.unreadable)
})
}
}
func TestChatResumesSignedThinkingHistory(t *testing.T) {
store := transcript.NewStore(t.TempDir())
w := store.NewWriter("conv-signed", "")
w.RecordMessage(llm.UserText("earlier question"), llm.Usage{})
w.RecordMessage(llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{
{Type: llm.BlockThinking, Thinking: "reasoning", Signature: "signed"},
{Type: llm.BlockRedactedThinking, Data: "opaque"},
{Type: llm.BlockText, Text: "earlier answer"},
}}, llm.Usage{})
if err := w.Err(); err != nil {
t.Fatal(err)
}
p := &sessionProbe{}
c := NewChatAgent(p, "test-model", t.TempDir(), store, 100000)
if _, err := c.Chat(t.Context(), "assistant", "conv-signed", "next question", 1, 0, false, nil); err != nil {
t.Fatal(err)
}
p.mu.Lock()
defer p.mu.Unlock()
if len(p.msgs) != 1 {
t.Fatalf("provider calls: %d", len(p.msgs))
}
var thinking, redacted bool
for _, m := range p.msgs[0] {
for _, b := range m.Content {
thinking = thinking || (b.Type == llm.BlockThinking && b.Thinking == "reasoning" && b.Signature == "signed")
redacted = redacted || (b.Type == llm.BlockRedactedThinking && b.Data == "opaque")
}
}
if !thinking || !redacted {
t.Fatalf("resumed history lost signed thinking=%v redacted=%v: %+v", thinking, redacted, p.msgs[0])
}
}
// sessionFixture is one exploration with one intent on the test database.
type sessionFixture struct {
expID int64
intentID int64
ts *db.ExplorationStore
intent *db.Node
}
func newSessionFixture(t *testing.T) sessionFixture {
t.Helper()
d := testDB(t)
t.Cleanup(func() { _ = d.Close() })
expID, err := d.CreateExploration("session identity probe", "keep the session id stable")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM explorations WHERE id=$1`, expID) })
ts := d.Exploration(expID)
payload := map[string]any{"summary": "session probe"}
intentID, err := ts.AddNode("intent", payload, 0, "running", "planner", nil)
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(payload)
return sessionFixture{expID: expID, intentID: intentID, ts: ts, intent: &db.Node{ID: intentID, Payload: raw}}
}
// run drives one agent kind once and returns the session id it should use.
func (f sessionFixture) run(t *testing.T, kind string, p *sessionProbe, tx *transcript.Store) (string, error) {
t.Helper()
workDir := t.TempDir()
switch kind {
case "worker":
_, _, err := NewWorker(p, "test-model", workDir, tx, 0, 3).Execute(t.Context(), "work#1", f.expID, nil, f.ts, f.intent, passHooks{}, nil, nil, nil)
return WorkerSessionID(f.expID, f.intentID), err
case "planner":
_, _, err := NewPlanner(p, "test-model", workDir, tx, 0, 3).Plan(t.Context(), f.expID, nil, f.ts, "probe goal", nil, passHooks{}, nil)
return fmt.Sprintf("exp%d-planner", f.expID), err
case "mainagent":
_, err := NewMainAgent(p, "test-model", workDir, tx, 0, 3).Chat(t.Context(), f.expID, 0, nil, f.ts, "probe goal", "status?", passHooks{}, nil, nil, nil, nil, nil)
return fmt.Sprintf("exp%d-main", f.expID), err
case "mainagent-seg":
_, err := NewMainAgent(p, "test-model", workDir, tx, 0, 3).Chat(t.Context(), f.expID, 2, nil, f.ts, "probe goal", "status?", passHooks{}, nil, nil, nil, nil, nil)
return fmt.Sprintf("exp%d-main-s2", f.expID), err
}
t.Fatalf("unknown agent kind %q", kind)
return "", nil
}
func TestAgentSessionIdentityWithAndWithoutTranscript(t *testing.T) {
f := newSessionFixture(t)
for _, kind := range []string{"worker", "planner", "mainagent", "mainagent-seg"} {
t.Run(kind, func(t *testing.T) {
bare := &sessionProbe{}
want, err := f.run(t, kind, bare, nil)
if err != nil {
t.Fatal(err)
}
store := transcript.NewStore(t.TempDir())
persisted := &sessionProbe{}
if _, err := f.run(t, kind, persisted, store); err != nil {
t.Fatal(err)
}
for _, ids := range [][]string{bare.seen(), persisted.seen()} {
if len(ids) == 0 {
t.Fatal("provider never called")
}
for _, id := range ids {
if id != want {
t.Fatalf("provider session ids %v, want %q", ids, want)
}
}
}
if recs, err := store.Load(store.MainPath(want)); err != nil || len(recs) == 0 {
t.Fatalf("transcript %q not written under the session id: %d records, err=%v", want, len(recs), err)
}
})
}
}
func TestAgentRefusesDamagedTranscript(t *testing.T) {
f := newSessionFixture(t)
for _, kind := range []string{"worker", "mainagent"} {
for _, tc := range damageCases {
t.Run(kind+"/"+tc.name, func(t *testing.T) {
store := transcript.NewStore(t.TempDir())
id := WorkerSessionID(f.expID, f.intentID)
if kind == "mainagent" {
id = fmt.Sprintf("exp%d-main", f.expID)
}
original := damageTranscript(t, store, id, tc.unreadable)
p := &sessionProbe{}
_, err := f.run(t, kind, p, store)
assertRestoreError(t, err, len(p.seen()))
assertTranscriptUntouched(t, store, id, original, tc.unreadable)
})
}
}
}
+4 -4
View File
@@ -46,9 +46,9 @@ const stagnationMinRepeats = 3
func detectStagnation(done, findings []*db.Node) []map[string]any {
type dir struct {
firstID int64
repeats int
summary string
firstID int64
repeats int
summary string
}
dirs := map[string]*dir{}
order := []string{}
@@ -551,7 +551,7 @@ func (t *ToolSet) graphOverviewData() map[string]any {
// with the data so no prompt-template change is needed on deployed installs.
if stale := detectStagnation(recentDone, vulnNodes); len(stale) > 0 {
out["stagnation_warning"] = map[string]any{
"directions": stale,
"directions": stale,
"instruction": "these directions were dispatched repeatedly with zero findings produced since their first attempt — do NOT re-dispatch the same approach again; open a materially different direction, or judge the goal on the existing evidence instead of grinding",
}
}
+10 -2
View File
@@ -540,9 +540,12 @@ func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db.
if hooks != nil { // typed-nil guard: only set when concrete (avoids harness panic)
opts.Hooks = hooks
}
// The session id is the provider-side identity (session header, usage metering)
// with or without persistence; Norma only puts it on ctx when it writes a transcript.
opts.SessionID = WorkerSessionID(ts.ID(), intent.ID)
ctx = transcript.WithSessionID(ctx, opts.SessionID)
if w.tx != nil { // persist raw LLM conversation; one file per worked intent
opts.Transcript = w.tx
opts.SessionID = WorkerSessionID(ts.ID(), intent.ID)
}
intentID := intent.ID
emitWrap := func(r db.Activity) {
@@ -574,7 +577,12 @@ func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db.
// restarting from scratch.
alreadyRecorded := false
if w.tx != nil {
_ = s.Resume(opts.SessionID)
// A corrupt or unreadable transcript stops the run before any model or tool
// call. No terminal reason: the engine marks the intent blocked without the
// model_error reruns, which would only fail the same way again.
if err := resumeSession(s, opts.SessionID); err != nil {
return "", tsx.Writes(), err
}
alreadyRecorded = requestID != "" && hasWorkerChatMessage(s.Messages(), requestID)
if len(s.Messages()) > 0 && message == "" {
seedUnlockFromHistory(s.Messages(), def.UnlockSkill)
+6 -6
View File
@@ -7,12 +7,12 @@ import "time"
// these match the *target asset*: an exact/fuzzy domain·ip·url, or a CIDR range.
// This layer only stores rules; the matching/enforcement logic lives elsewhere.
type AssetInterceptRule struct {
ID int64 `json:"id"`
Enabled bool `json:"enabled"`
Kind string `json:"kind"` // exact_domain|exact_ip|exact_url|fuzzy_domain|fuzzy_ip|fuzzy_url|cidr
Pattern string `json:"pattern"`
Note string `json:"note"`
Builtin bool `json:"builtin"`
ID int64 `json:"id"`
Enabled bool `json:"enabled"`
Kind string `json:"kind"` // exact_domain|exact_ip|exact_url|fuzzy_domain|fuzzy_ip|fuzzy_url|cidr
Pattern string `json:"pattern"`
Note string `json:"note"`
Builtin bool `json:"builtin"`
// Action is only used by task-level rules: 'block'=intercept, 'allow'=allow (whitelist).
// Global rules (asset_intercept_rules) do not have this column; it is always empty and treated as intercept.
Action string `json:"action,omitempty"`
+6 -6
View File
@@ -1168,11 +1168,11 @@ func TestQueryDSLInScopeMembership(t *testing.T) {
}
stamp := time.Now().UnixNano()
root := fmt.Sprintf("sc%d.invalid", stamp) // in-scope root domain (task)
srcRoot := fmt.Sprintf("src%d.invalid", stamp) // in-scope via source task
out := fmt.Sprintf("out%d.invalid", stamp) // out of every scope
marker := fmt.Sprintf("mk%d", stamp) // bare-text token present in all rows
foreignTask := stamp + 777 // asset produced by an unrelated task
root := fmt.Sprintf("sc%d.invalid", stamp) // in-scope root domain (task)
srcRoot := fmt.Sprintf("src%d.invalid", stamp) // in-scope via source task
out := fmt.Sprintf("out%d.invalid", stamp) // out of every scope
marker := fmt.Sprintf("mk%d", stamp) // bare-text token present in all rows
foreignTask := stamp + 777 // asset produced by an unrelated task
// Scope: task owns root; source task owns srcRoot; task owns an IP /24.
for _, sc := range []struct {
@@ -1200,7 +1200,7 @@ func TestQueryDSLInScopeMembership(t *testing.T) {
{"subdomain", "api." + root, root, "", "", "", marker}, // under task root
{"service", "www." + root, root, "https://www." + root + "/", "", "", marker}, // service under task root
{"endpoint", "www." + root, root, "https://www." + root + "/a?" + marker + "=1", "GET", "", ""},
{"subdomain", "dev." + srcRoot, srcRoot, "", "", "", marker}, // under source-task root
{"subdomain", "dev." + srcRoot, srcRoot, "", "", "", marker}, // under source-task root
{"endpoint", "198.51.100.9", "198.51.100.9", "http://198.51.100.9:8080/" + marker, "GET", "", ""}, // IP-literal host, ip col empty
{"subdomain", "x." + out, out, "", "", "", marker}, // out of scope
}
+50 -26
View File
@@ -1486,11 +1486,13 @@ type TaskListMetrics struct {
Findings FindingSeverityCounts // this task's vulnerability count in the findings table (bucketed by severity)
}
// TaskListMetricsAll returns list aggregates for every live task in one query.
// The lateral lookups use the per-exploration indexes instead of grouping the
// complete activity history on every poll.
func (d *DB) TaskListMetricsAll() (map[int64]TaskListMetrics, error) {
rows, err := d.Query(`
// taskListMetricsQuery is the select shared by TaskListMetricsAll and
// TaskListMetrics; each appends its own WHERE on task. The lateral lookups use
// the per-exploration indexes instead of grouping the complete activity history
// on every poll. latest_activity orders by the column activity.created_at, so
// idx_act_latest (exploration_id, created_at DESC) serves the LIMIT 1; ordering
// by the output alias would sort every activity row of the task.
const taskListMetricsQuery = `
SELECT task.exploration_id,
COALESCE(token_metrics.input_tokens,0),
COALESCE(token_metrics.output_tokens,0),
@@ -1514,10 +1516,10 @@ func (d *DB) TaskListMetricsAll() (map[int64]TaskListMetrics, error) {
WHERE exploration_id=task.exploration_id AND kind='result'
) token_metrics ON true
LEFT JOIN LATERAL (
SELECT EXTRACT(EPOCH FROM created_at)::bigint AS created_at
SELECT EXTRACT(EPOCH FROM activity.created_at)::bigint AS created_at
FROM activity
WHERE exploration_id=task.exploration_id
ORDER BY created_at DESC
WHERE activity.exploration_id=task.exploration_id
ORDER BY activity.created_at DESC
LIMIT 1
) latest_activity ON true
LEFT JOIN LATERAL (
@@ -1539,30 +1541,41 @@ func (d *DB) TaskListMetricsAll() (map[int64]TaskListMetrics, error) {
FROM findings
WHERE task_id=task.id
) finding_metrics ON true
WHERE task.deleted_at IS NULL`)
WHERE task.deleted_at IS NULL`
// scanTaskListMetrics reads one row of taskListMetricsQuery.
func scanTaskListMetrics(row interface{ Scan(...any) error }) (int64, TaskListMetrics, error) {
var explorationID int64
var metrics TaskListMetrics
err := row.Scan(
&explorationID,
&metrics.Tokens.InputTokens,
&metrics.Tokens.OutputTokens,
&metrics.Tokens.CacheReadTokens,
&metrics.Tokens.CacheWriteTokens,
&metrics.LastActivity,
&metrics.Goals.Total,
&metrics.Goals.Met,
&metrics.RunningIntents,
&metrics.Findings.Critical,
&metrics.Findings.High,
&metrics.Findings.Medium,
&metrics.Findings.Low,
)
return explorationID, metrics, err
}
// TaskListMetricsAll returns list aggregates for every live task in one query.
func (d *DB) TaskListMetricsAll() (map[int64]TaskListMetrics, error) {
rows, err := d.Query(taskListMetricsQuery)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]TaskListMetrics{}
for rows.Next() {
var explorationID int64
var metrics TaskListMetrics
if err := rows.Scan(
&explorationID,
&metrics.Tokens.InputTokens,
&metrics.Tokens.OutputTokens,
&metrics.Tokens.CacheReadTokens,
&metrics.Tokens.CacheWriteTokens,
&metrics.LastActivity,
&metrics.Goals.Total,
&metrics.Goals.Met,
&metrics.RunningIntents,
&metrics.Findings.Critical,
&metrics.Findings.High,
&metrics.Findings.Medium,
&metrics.Findings.Low,
); err != nil {
explorationID, metrics, err := scanTaskListMetrics(rows)
if err != nil {
return nil, err
}
out[explorationID] = metrics
@@ -1570,6 +1583,17 @@ func (d *DB) TaskListMetricsAll() (map[int64]TaskListMetrics, error) {
return out, rows.Err()
}
// TaskListMetrics returns the same aggregates for the one live task of
// exploration expID, with the same laterals as TaskListMetricsAll. A missing or
// deleted task gives zero metrics, as a missing key of TaskListMetricsAll does.
func (d *DB) TaskListMetrics(expID int64) (TaskListMetrics, error) {
_, metrics, err := scanTaskListMetrics(d.QueryRow(taskListMetricsQuery+` AND task.exploration_id=$1`, expID))
if errors.Is(err, sql.ErrNoRows) {
return TaskListMetrics{}, nil
}
return metrics, err
}
// GoalCountsAll returns goal totals (total / met) per exploration id, one query
// for all tasks — used by listTasks so the task list shows progress for every task.
func (d *DB) GoalCountsAll() (map[int64]GoalCounts, error) {
+6 -5
View File
@@ -341,11 +341,12 @@ func applyCostEur(d *DB, out []ProfileUsage) error {
if !ok || (pr.in == 0 && pr.outp == 0 && pr.cr == 0 && pr.cw == 0) {
continue
}
// InputTokens includes the cached subset (Anthropic and OpenAI usage
// semantics), so the fresh share is input minus cache reads; charging
// both buckets at full input price double-counts the cache. Clamped at
// zero for gateways that report the buckets disjointly.
fresh := out[i].InputTokens - out[i].CacheReadTokens
// InputTokens already includes both cached subsets: Norma normalizes it to
// input + cache read + cache write (OpenAI prompt_tokens semantics). The
// fresh share is therefore input minus cache reads minus cache writes;
// charging either subset at full input price as well bills it twice.
// Clamped at zero for gateways that report the buckets disjointly.
fresh := out[i].InputTokens - out[i].CacheReadTokens - out[i].CacheWriteTokens
if fresh < 0 {
fresh = 0
}
+125
View File
@@ -0,0 +1,125 @@
package db
import (
"fmt"
"math"
"testing"
"time"
)
// costProfiles saves a priced profile (EUR per million tokens: input 2.00,
// output 4.00, cache read 0.20, cache write 2.50) and an unpriced one.
func costProfiles(t *testing.T, d *DB) (priced, unpriced string) {
t.Helper()
suffix := time.Now().UnixNano()
priced, unpriced = fmt.Sprintf("t-cost-priced-%d", suffix), fmt.Sprintf("t-cost-unpriced-%d", suffix)
for _, p := range []*LLMProfile{
{Name: priced, Format: "anthropic", Model: "m", APIKey: "k",
PriceInputPerM: 2, PriceOutputPerM: 4, PriceCacheReadPerM: 0.2, PriceCacheWritePerM: 2.5},
{Name: unpriced, Format: "anthropic", Model: "m", APIKey: "k"},
} {
id, err := d.SaveProfile(p)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, id) })
}
return priced, unpriced
}
func assertCost(t *testing.T, got *float64, want float64) {
t.Helper()
if got == nil {
t.Fatalf("cost=nil, want %.2f", want)
}
if math.Abs(*got-want) > 1e-9 {
t.Fatalf("cost=%.4f, want %.2f", *got, want)
}
}
// InputTokens already includes the cache read and cache write subsets, so only
// the remainder is billed at the input price.
func TestCostSeparatesFreshReadAndWriteBuckets(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Fatal(err)
}
defer d.Close()
priced, unpriced := costProfiles(t, d)
// Every case adds 100k output tokens: 0.1 × 4.00 = 0.40.
for _, tc := range []struct {
name string
in, read, write int
want float64
}{
{"fresh", 1_000_000, 0, 0, 2.40}, // 1M×2.00 + 0.40
{"read_only", 1_000_000, 1_000_000, 0, 0.60}, // 0 fresh + 1M×0.20 + 0.40
{"write_only", 1_000_000, 0, 1_000_000, 2.90}, // 0 fresh + 1M×2.50 + 0.40
{"mixed", 1_000_000, 200_000, 300_000, 2.19}, // 500k×2.00 + 200k×0.20 + 300k×2.50 + 0.40
{"inconsistent_gateway", 100_000, 200_000, 300_000, 1.19}, // fresh clamped to 0 + 0.04 + 0.75 + 0.40
} {
t.Run(tc.name, func(t *testing.T) {
out := []ProfileUsage{
{ProfileName: priced, InputTokens: tc.in, CacheReadTokens: tc.read, CacheWriteTokens: tc.write, OutputTokens: 100_000},
{ProfileName: unpriced, InputTokens: tc.in, CacheReadTokens: tc.read, CacheWriteTokens: tc.write, OutputTokens: 100_000},
{ProfileName: "t-cost-unknown-profile", InputTokens: tc.in, OutputTokens: 100_000},
}
if err := applyCostEur(d, out); err != nil {
t.Fatal(err)
}
assertCost(t, out[0].CostEur, tc.want)
if out[1].CostEur != nil || out[2].CostEur != nil {
t.Fatalf("unpriced usage got a cost: %v %v", out[1].CostEur, out[2].CostEur)
}
})
}
}
// Several ledger rows of one profile are summed before pricing; with no clamp
// involved the profile cost equals the sum of the per-row costs.
func TestUsageByProfileCostAggregatesRows(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Fatal(err)
}
defer d.Close()
priced, unpriced := costProfiles(t, d)
t.Cleanup(func() { d.Exec(`DELETE FROM llm_usage WHERE profile_name IN ($1,$2)`, priced, unpriced) })
rows := []LLMUsage{
// fresh 400k×2.00 + 200k×0.20 + 40k×4.00 = 0.80 + 0.04 + 0.16 = 1.00
{InputTokens: 600_000, CacheRead: 200_000, OutputTokens: 40_000},
// fresh 100k×2.00 + 300k×2.50 + 60k×4.00 = 0.20 + 0.75 + 0.24 = 1.19
{InputTokens: 400_000, CacheWrite: 300_000, OutputTokens: 60_000},
}
for _, profile := range []string{priced, unpriced} {
for _, r := range rows {
r.ProfileName, r.Model, r.Status = profile, "m", "ok"
if err := d.InsertLLMUsage(&r); err != nil {
t.Fatal(err)
}
}
}
usage, err := d.UsageByProfile()
if err != nil {
t.Fatal(err)
}
found := 0
for _, p := range usage {
switch p.ProfileName {
case priced:
found++
if p.Calls != 2 || p.InputTokens != 1_000_000 || p.CacheReadTokens != 200_000 || p.CacheWriteTokens != 300_000 || p.OutputTokens != 100_000 {
t.Fatalf("aggregate %+v", p)
}
assertCost(t, p.CostEur, 2.19) // = 1.00 + 1.19, the mixed case
case unpriced:
found++
if p.Calls != 2 || p.CostEur != nil {
t.Fatalf("unpriced aggregate %+v", p)
}
}
}
if found != 2 {
t.Fatalf("profiles missing from UsageByProfile: found %d of 2", found)
}
}
+130
View File
@@ -117,3 +117,133 @@ VALUES ($1,'goal','{}','met'), ($1,'goal','{}','open')`, task.ExplorationID); er
t.Fatalf("unexpected goal metrics: %+v", metrics.Goals)
}
}
// TaskListMetrics(expID) is exactly the row TaskListMetricsAll returns for that
// exploration, on every counter, and TaskListMetricsAll still lists every task.
func TestTaskListMetricsMatchesAll(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
t.Cleanup(func() { d.Close() })
newest := time.Now().Add(-time.Minute).Truncate(time.Second)
seed := func(name string, n int) *Task {
t.Helper()
task, err := d.CreateTask(name, "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, err := d.DeleteTaskCascade(task.ID, false, true)
cleanupErr(t, "task "+name, err)
})
exp := task.ExplorationID
// n result rows with tokens; the newest activity first, then an older row
// with a higher id, so ordering by id instead of created_at would fail.
for _, q := range []struct {
sql string
args []any
}{
{`INSERT INTO activity(exploration_id,kind,input_tokens,output_tokens,cache_read_tokens,cache_write_tokens,created_at)
SELECT $1,'result',10,5,3,1,$3::timestamptz - interval '1 hour' FROM generate_series(1,$2::int)`, []any{exp, n, newest}},
{`INSERT INTO activity(exploration_id,kind,created_at) VALUES ($1,'text',$2::timestamptz)`, []any{exp, newest}},
{`INSERT INTO activity(exploration_id,kind,created_at) SELECT $1,'text',$3::timestamptz - interval '2 hours' FROM generate_series(1,$2::int)`, []any{exp, n, newest}},
{`INSERT INTO exploration_nodes(exploration_id,kind,payload,state)
SELECT $1,'goal','{}',CASE WHEN g<=$2::int THEN 'met' ELSE 'open' END FROM generate_series(1,$2::int+1) g`, []any{exp, n}},
{`INSERT INTO exploration_nodes(exploration_id,kind,payload,state)
SELECT $1,'intent','{}',CASE WHEN g<=$2::int THEN 'running' ELSE 'done' END FROM generate_series(1,$2::int+2) g`, []any{exp, n}},
} {
if _, err := d.Exec(q.sql, q.args...); err != nil {
t.Fatal(err)
}
}
if _, err := d.Exec(`INSERT INTO findings(task_id,severity)
SELECT $1, s FROM unnest(ARRAY['critical','high','high','medium','low','info']) s, generate_series(1,$2)`, task.ID, n); err != nil {
t.Fatal(err)
}
return task
}
a := seed("task-list-metrics-a", 2)
b := seed("task-list-metrics-b", 3)
all, err := d.TaskListMetricsAll()
if err != nil {
t.Fatal(err)
}
for _, c := range []struct {
task *Task
n int
}{{a, 2}, {b, 3}} {
row, ok := all[c.task.ExplorationID]
if !ok {
t.Fatalf("TaskListMetricsAll misses task %d", c.task.ID)
}
one, err := d.TaskListMetrics(c.task.ExplorationID)
if err != nil {
t.Fatal(err)
}
if one != row {
t.Fatalf("task %d: TaskListMetrics=%+v, TaskListMetricsAll=%+v", c.task.ID, one, row)
}
n := c.n
want := TaskListMetrics{
Tokens: TokenUsage{InputTokens: 10 * n, OutputTokens: 5 * n, CacheReadTokens: 3 * n, CacheWriteTokens: n},
LastActivity: newest.Unix(),
Goals: GoalCounts{Total: n + 1, Met: n},
RunningIntents: n,
Findings: FindingSeverityCounts{Critical: n, High: 2 * n, Medium: n, Low: n},
}
if one != want {
t.Fatalf("task %d metrics=%+v, want %+v", c.task.ID, one, want)
}
}
if none, err := d.TaskListMetrics(-1); err != nil || none != (TaskListMetrics{}) {
t.Fatalf("missing exploration: %+v, %v; want zero metrics", none, err)
}
}
// BenchmarkTaskListMetrics compares the per-exploration metrics query with the
// all-tasks one on three tasks of 40,000 activity rows each. No time assertion.
func BenchmarkTaskListMetrics(b *testing.B) {
dsn, _, err := DSN()
if err != nil {
b.Skipf("no database config (%v) - skipping", err)
}
d, err := Open(dsn)
if err != nil {
b.Skipf("postgres unavailable (%v) - skipping", err)
}
b.Cleanup(func() { d.Close() })
var exp int64
for i := range 3 {
task, err := d.CreateTask(fmt.Sprintf("bench-task-list-metrics-%d", i), "goal", nil, 0, 0)
if err != nil {
b.Fatal(err)
}
b.Cleanup(func() { _, _ = d.DeleteTaskCascade(task.ID, false, true) })
if _, err := d.Exec(`INSERT INTO activity(exploration_id,kind,input_tokens,created_at)
SELECT $1, CASE WHEN g%20=0 THEN 'result' ELSE 'tool_use' END, 100, now() - (g||' seconds')::interval
FROM generate_series(1,40000) g`, task.ExplorationID); err != nil {
b.Fatal(err)
}
exp = task.ExplorationID
}
if _, err := d.Exec(`ANALYZE activity`); err != nil {
b.Fatal(err)
}
b.Run("one", func(b *testing.B) {
for range b.N {
if _, err := d.TaskListMetrics(exp); err != nil {
b.Fatal(err)
}
}
})
b.Run("all", func(b *testing.B) {
for range b.N {
if _, err := d.TaskListMetricsAll(); err != nil {
b.Fatal(err)
}
}
})
}
+8 -8
View File
@@ -6,14 +6,14 @@ import "testing"
// ip/cidr scope path so a target like "10.0.188.136:3000" no longer 404s.
func TestStripHostPort(t *testing.T) {
cases := []struct{ in, want string }{
{"10.0.188.136:3000", "10.0.188.136"}, // the reported IP case
{"10.0.188.136", "10.0.188.136"}, // bare IPv4 unchanged
{"[2001:db8::1]:8080", "2001:db8::1"}, // bracketed IPv6 + port
{"2001:db8::1", "2001:db8::1"}, // bare IPv6 unchanged (has colons)
{" 1.2.3.4:80 ", "1.2.3.4"}, // trims surrounding space
{"example.com:443", "example.com"}, // domain + port → bare host
{"api.example.com:8080", "api.example.com"}, // subdomain + port
{"example.com", "example.com"}, // bare domain unchanged
{"10.0.188.136:3000", "10.0.188.136"}, // the reported IP case
{"10.0.188.136", "10.0.188.136"}, // bare IPv4 unchanged
{"[2001:db8::1]:8080", "2001:db8::1"}, // bracketed IPv6 + port
{"2001:db8::1", "2001:db8::1"}, // bare IPv6 unchanged (has colons)
{" 1.2.3.4:80 ", "1.2.3.4"}, // trims surrounding space
{"example.com:443", "example.com"}, // domain + port → bare host
{"api.example.com:8080", "api.example.com"}, // subdomain + port
{"example.com", "example.com"}, // bare domain unchanged
}
for _, c := range cases {
if got := stripHostPort(c.in); got != c.want {
+11 -11
View File
@@ -28,19 +28,19 @@ type Task struct {
LLMProfileID *int64 `json:"llm_profile_id,omitempty"`
// Task-level ordered LLM chain. LLMProfileID remains the compatibility alias
// for ActiveLLMProfileID while older API clients still send one profile id.
LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"`
ActiveLLMProfileID *int64 `json:"active_llm_profile_id,omitempty"`
LLMChainRevision int64 `json:"-"`
LLMFailoverState string `json:"llm_failover_state,omitempty"`
LLMFailoverReason string `json:"llm_failover_reason,omitempty"`
LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"`
ActiveLLMProfileID *int64 `json:"active_llm_profile_id,omitempty"`
LLMChainRevision int64 `json:"-"`
LLMFailoverState string `json:"llm_failover_state,omitempty"`
LLMFailoverReason string `json:"llm_failover_reason,omitempty"`
// LLMRotate: per-intent chain-head rotation switch (#85); false = classic
// ordered failover from the active head.
LLMRotate bool `json:"llm_rotate"`
SourceTaskIDs []int64 `json:"source_task_ids,omitempty"`
CompanyIDs []int64 `json:"company_ids,omitempty"`
ParentRef string `json:"parent_ref,omitempty"` // parent task id (recorded by orchestration spawn; empty=top level)
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"` // when it entered a terminal state (done/failed/timeout); nil for non-terminal
LLMRotate bool `json:"llm_rotate"`
SourceTaskIDs []int64 `json:"source_task_ids,omitempty"`
CompanyIDs []int64 `json:"company_ids,omitempty"`
ParentRef string `json:"parent_ref,omitempty"` // parent task id (recorded by orchestration spawn; empty=top level)
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"` // when it entered a terminal state (done/failed/timeout); nil for non-terminal
// Task-level timeout (see the task-level timeout and wrap-up design doc).
TimeoutSeconds int `json:"timeout_seconds"` // 0=no time limit
FirstRunAt *time.Time `json:"first_run_at,omitempty"` // when it first actually started running (not created_at); nil=not yet run
+6 -6
View File
@@ -195,11 +195,11 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S
return
}
// Always track usage (cheap); accumulate text/thinking only for bodies.
// Anthropic (and the other providers) split token usage across events:
// message_start carries ONLY the input side (input + cache), message_delta
// ONLY the output. They must be FOLDED with Add — overwriting on delta
// would zero out the input already counted at start (mirrors the SDK's own
// llm.Accumulator; see norma/llm/accumulate.go).
// message_start carries the input side (input + cache) and is added.
// Since Norma v0.4.7 message_delta also echoes that input side next to the
// output, so it is folded with llm.Usage.MergeEcho (max on echoed fields,
// sum on output), as llm.Accumulator does; Add would count the input and
// cache twice. An output-only delta folds the same either way.
switch ev.Type {
case llm.SETextDelta:
if recordBodies {
@@ -215,7 +215,7 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S
if ev.StopReason != "" {
stopReason = ev.StopReason
}
usage.Add(ev.Usage)
usage.MergeEcho(ev.Usage)
}
if !yield(ev, err) {
return
+118
View File
@@ -0,0 +1,118 @@
package llmrec
import (
"context"
"iter"
"testing"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/llm"
"github.com/google/uuid"
)
// echoStreamProvider streams one message_start and one message_delta with the
// given (already normalized) usage, as Norma's providers emit them.
type echoStreamProvider struct {
completeProvider
start, delta llm.Usage
}
func (p echoStreamProvider) Stream(context.Context, llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
return func(yield func(llm.StreamEvent, error) bool) {
for _, ev := range []llm.StreamEvent{
{Type: llm.SEMessageStart, Usage: p.start},
{Type: llm.SETextDelta, Text: "answer"},
{Type: llm.SEMessageDelta, StopReason: "end_turn", Usage: p.delta},
{Type: llm.SEMessageStop},
} {
if !yield(ev, nil) {
return
}
}
}
}
// Norma normalizes Anthropic usage so InputTokens includes the cache read and
// write subsets, and since v0.4.7 message_delta echoes that input side.
var usageEchoCases = []struct {
name string
start, delta llm.Usage
want llm.Usage
}{
{
name: "full_echo",
start: llm.Usage{InputTokens: 23, CacheReadTokens: 5, CacheWriteTokens: 3},
delta: llm.Usage{InputTokens: 23, CacheReadTokens: 5, CacheWriteTokens: 3, OutputTokens: 5},
want: llm.Usage{InputTokens: 23, OutputTokens: 5, CacheReadTokens: 5, CacheWriteTokens: 3},
},
{
// Raw start: input 500, cache read 200, cache creation 40 → normalized 740.
// Raw delta: input 500, output 77, no cache fields → normalized 500.
name: "partial_echo",
start: llm.Usage{InputTokens: 740, CacheReadTokens: 200, CacheWriteTokens: 40},
delta: llm.Usage{InputTokens: 500, OutputTokens: 77},
want: llm.Usage{InputTokens: 740, OutputTokens: 77, CacheReadTokens: 200, CacheWriteTokens: 40},
},
{
name: "no_echo",
start: llm.Usage{InputTokens: 23, CacheReadTokens: 5, CacheWriteTokens: 3},
delta: llm.Usage{OutputTokens: 5},
want: llm.Usage{InputTokens: 23, OutputTokens: 5, CacheReadTokens: 5, CacheWriteTokens: 3},
},
}
func drainRecorderStream(t *testing.T, ctx context.Context, r *Recorder) {
t.Helper()
for _, err := range r.Stream(ctx, llm.CompletionRequest{}) {
if err != nil {
t.Fatal(err)
}
}
}
// The metered usage of a streamed call counts an echoed input side once.
func TestStreamUsageEchoCountedOnce(t *testing.T) {
for _, tc := range usageEchoCases {
t.Run(tc.name, func(t *testing.T) {
var got []llm.Usage
ctx := WithUsageSink(t.Context(), func(u llm.Usage) { got = append(got, u) })
drainRecorderStream(t, ctx, Wrap(echoStreamProvider{start: tc.start, delta: tc.delta}, nil, "fixture", "profile", "", "", func() bool { return false }))
if len(got) != 1 || got[0] != tc.want {
t.Fatalf("metered %+v, want one call with %+v", got, tc.want)
}
})
}
}
// The llm_usage ledger gets one row per streamed call, with the echo counted once.
func TestStreamUsageEchoLedgerRow(t *testing.T) {
dsn, _, err := db.DSN()
if err != nil {
t.Skipf("no database config (%v)", err)
}
pg, err := db.Open(dsn)
if err != nil {
t.Skipf("postgres unavailable (%v)", err)
}
defer pg.Close()
if err := pg.EnsureLLMUsageTable(); err != nil {
t.Fatal(err)
}
for _, tc := range usageEchoCases {
t.Run(tc.name, func(t *testing.T) {
profile := "usage-echo-" + uuid.NewString()
t.Cleanup(func() { _, _ = pg.Exec(`DELETE FROM llm_usage WHERE profile_name=$1`, profile) })
drainRecorderStream(t, t.Context(), Wrap(echoStreamProvider{start: tc.start, delta: tc.delta}, pg, "fixture", profile, "", "", func() bool { return false }))
var rows int
var got llm.Usage
err := pg.QueryRow(`SELECT count(*), COALESCE(sum(input_tokens),0), COALESCE(sum(output_tokens),0), COALESCE(sum(cache_read),0), COALESCE(sum(cache_write),0)
FROM llm_usage WHERE profile_name=$1`, profile).Scan(&rows, &got.InputTokens, &got.OutputTokens, &got.CacheReadTokens, &got.CacheWriteTokens)
if err != nil {
t.Fatal(err)
}
if rows != 1 || got != tc.want {
t.Fatalf("ledger rows=%d usage=%+v, want 1 row with %+v", rows, got, tc.want)
}
})
}
}
+1 -1
View File
@@ -6,9 +6,9 @@ import (
"path/filepath"
"testing"
actool "github.com/Autumn-27/norma/tool"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
actool "github.com/Autumn-27/norma/tool"
)
// hasSkillTool reports whether the packed tool set contains the Skill meta-tool.
+9 -9
View File
@@ -7,19 +7,19 @@ import (
func TestGoalGateCheck(t *testing.T) {
good := map[string]string{
"url target": "Audit " + strings.Repeat("x ", 60) + " at https://example.com/app every module for injection issues and record one finding per issue with evidence.",
"bare domain": strings.Repeat("probe ", 25) + "the api at target.example.org for unauthenticated data exposure, one record proof, then stop.",
"ipv4": strings.Repeat("recon ", 25) + "10.0.0.5 and map every listening service before any test.",
"localhost port": strings.Repeat("verify ", 25) + "localhost:3000 health endpoints respond identically before and after config reload.",
"path": strings.Repeat("review ", 25) + "the module at /app/data/core for hardcoded secrets and report each hit.",
"targetless opt-out": "[targetless] " + strings.Repeat("summarize ", 20) + "the attached material into a prioritized hypothesis list.",
"url target": "Audit " + strings.Repeat("x ", 60) + " at https://example.com/app every module for injection issues and record one finding per issue with evidence.",
"bare domain": strings.Repeat("probe ", 25) + "the api at target.example.org for unauthenticated data exposure, one record proof, then stop.",
"ipv4": strings.Repeat("recon ", 25) + "10.0.0.5 and map every listening service before any test.",
"localhost port": strings.Repeat("verify ", 25) + "localhost:3000 health endpoints respond identically before and after config reload.",
"path": strings.Repeat("review ", 25) + "the module at /app/data/core for hardcoded secrets and report each hit.",
"targetless opt-out": "[targetless] " + strings.Repeat("summarize ", 20) + "the attached material into a prioritized hypothesis list.",
}
good["targetless short but marked"] = "[targetless] analyze nothing"
bad := map[string]string{
"no target long": strings.Repeat("do a thorough security review of everything and report anything interesting that comes up during the exploration phase of this audit, thanks. ", 2),
"too short": "audit the site",
"short and bare": "find bugs",
"no target long": strings.Repeat("do a thorough security review of everything and report anything interesting that comes up during the exploration phase of this audit, thanks. ", 2),
"too short": "audit the site",
"short and bare": "find bugs",
}
for name, g := range good {
if name == "targetless short+:url" {
+7 -7
View File
@@ -55,13 +55,13 @@ type Task struct {
LLMChainRevision int64 `json:"-"`
LLMFailoverState string `json:"llm_failover_state,omitempty"`
// LLMRotate (#85): per-intent round-robin over the task's LLM chain heads.
LLMRotate bool `json:"llm_rotate,omitempty"`
rotateMu sync.Mutex
rotateNext int
LLMFailoverReason string `json:"llm_failover_reason,omitempty"`
SourceTaskIDs []int64 `json:"source_task_ids,omitempty"`
CompanyIDs []int64 `json:"company_ids,omitempty"`
Status string `json:"status"` // persisted lifecycle status (done/failed/timeout are terminal; empty/other is derived from runtime state)
LLMRotate bool `json:"llm_rotate,omitempty"`
rotateMu sync.Mutex
rotateNext int
LLMFailoverReason string `json:"llm_failover_reason,omitempty"`
SourceTaskIDs []int64 `json:"source_task_ids,omitempty"`
CompanyIDs []int64 `json:"company_ids,omitempty"`
Status string `json:"status"` // persisted lifecycle status (done/failed/timeout are terminal; empty/other is derived from runtime state)
// Task-level timeout (see docs/task-timeout-and-settle-design.md). DeadlineAt/FirstRunAt are unix seconds, 0 = unset/not yet run.
TimeoutSeconds int `json:"timeout_seconds"`
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner heartbeat trigger interval (seconds)
+7 -7
View File
@@ -1609,12 +1609,12 @@ type createTaskReq struct {
CategoryID *int64 `json:"category_id,omitempty"`
Description string `json:"description"`
Goal string `json:"goal"`
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // the LLM profile to run this task on; omitted/null = use the active profile
LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"` // ordered task-level profile chain; the first entry takes effect initially
SourceTaskIDs []string `json:"source_task_ids,omitempty"` // direct, read-only inherited source tasks
CompanyIDs []int64 `json:"company_ids,omitempty"` // links the company scope and snapshots its current assets; does not copy assets or force intent generation
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // the LLM profile to run this task on; omitted/null = use the active profile
LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"` // ordered task-level profile chain; the first entry takes effect initially
SourceTaskIDs []string `json:"source_task_ids,omitempty"` // direct, read-only inherited source tasks
CompanyIDs []int64 `json:"company_ids,omitempty"` // links the company scope and snapshots its current assets; does not copy assets or force intent generation
TimeoutSeconds int `json:"timeout_seconds"`
LLMRotate *bool `json:"llm_rotate"` // task-level timeout (seconds); 0/omitted = no limit
LLMRotate *bool `json:"llm_rotate"` // task-level timeout (seconds); 0/omitted = no limit
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner heartbeat interval (seconds); 0/omitted = default 600 (10min); floor = default = 600, lower values are raised to 600
PlanQuietMode *bool `json:"plan_quiet_mode,omitempty"` // planner quiet-mode switch (#86); omitted = default on; false = edge rounds always plan
TargetGate *bool `json:"target_gate,omitempty"` // approval gate for asset targets, opt-in; omitted/null = off
@@ -1927,11 +1927,11 @@ func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
return
}
dto := taskDTO(t, s.resolvedTaskStatus(t))
metrics, err := s.m.PG().TaskListMetricsAll()
metrics, err := s.m.PG().TaskListMetrics(t.ExpID)
if err != nil {
log.Printf("[tasks] detail metrics query failed for %s — metrics zeroed: %v", t.ID, err)
}
s.applyTaskMetrics(&dto, t, metrics[t.ExpID])
s.applyTaskMetrics(&dto, t, metrics)
archiveBlockers, _ := s.m.PG().TaskArchiveBlockers()
applyTaskArchiveBlocker(&dto, archiveBlockers)
writeJSON(w, 200, dto)
+47 -10
View File
@@ -38,6 +38,21 @@ VALUES ($1,'goal','{}','met'), ($1,'goal','{}','open')`, task.ExpID); err != nil
t.Fatal(err)
}
// A second task, so the detail must pick its own row and the list must keep
// serving every task.
other, err := m.CreateTask("detail metrics other", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
otherID, _ := strconv.ParseInt(other.ID, 10, 64)
defer func() { _, _ = m.pg.DeleteTaskCascade(otherID, false, true) }()
defer func() { _, _ = m.pg.Exec(`DELETE FROM findings WHERE task_id=$1`, taskID) }()
if _, err := m.pg.Exec(`
INSERT INTO findings(task_id, severity) VALUES ($1,'critical'), ($1,'high'), ($1,'high'), ($1,'low'), ($2,'medium')`,
taskID, otherID); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
defer stopTestServer(t, s, cancel)
@@ -45,7 +60,19 @@ VALUES ($1,'goal','{}','met'), ($1,'goal','{}','open')`, task.ExpID); err != nil
if err != nil {
t.Fatal(err)
}
// After New: restoring the task resets running intents to open.
if _, err := m.pg.Exec(`
INSERT INTO exploration_nodes(exploration_id, kind, payload, state)
VALUES ($1,'intent','{}','running'), ($1,'intent','{}','done')`, task.ExpID); err != nil {
t.Fatal(err)
}
type findingCounts struct {
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
}
var probe struct {
Tokens struct {
InputTokens int `json:"input_tokens"`
@@ -53,8 +80,10 @@ VALUES ($1,'goal','{}','met'), ($1,'goal','{}','open')`, task.ExpID); err != nil
CacheReadTokens int `json:"cache_read_tokens"`
CacheWriteTokens int `json:"cache_write_tokens"`
} `json:"tokens"`
GoalsMet int `json:"goals_met"`
GoalsTotal int `json:"goals_total"`
GoalsMet int `json:"goals_met"`
GoalsTotal int `json:"goals_total"`
InFlight int `json:"in_flight"`
Findings findingCounts `json:"findings"`
}
req := httptest.NewRequest(http.MethodGet, "/api/tasks/"+task.ID, nil)
req.Header.Set("Authorization", "Bearer "+token)
@@ -73,6 +102,9 @@ VALUES ($1,'goal','{}','met'), ($1,'goal','{}','open')`, task.ExpID); err != nil
if probe.GoalsMet != 1 || probe.GoalsTotal != 2 {
t.Fatalf("detail goal progress not filled from metrics: %d/%d", probe.GoalsMet, probe.GoalsTotal)
}
if probe.InFlight != 1 || probe.Findings != (findingCounts{Critical: 1, High: 2, Low: 1}) {
t.Fatalf("detail in-flight/findings not filled from this task's metrics: %d %+v", probe.InFlight, probe.Findings)
}
// The list endpoint must report the same numbers for the same task.
listReq := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
@@ -89,27 +121,32 @@ VALUES ($1,'goal','{}','met'), ($1,'goal','{}','open')`, task.ExpID); err != nil
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"tokens"`
GoalsMet int `json:"goals_met"`
GoalsTotal int `json:"goals_total"`
GoalsMet int `json:"goals_met"`
GoalsTotal int `json:"goals_total"`
InFlight int `json:"in_flight"`
Findings findingCounts `json:"findings"`
} `json:"tasks"`
}
if err := json.Unmarshal(listRec.Body.Bytes(), &listed); err != nil {
t.Fatal(err)
}
found := false
found, foundOther := false, false
for _, lt := range listed.Tasks {
if lt.ID == other.ID {
foundOther = lt.Findings == (findingCounts{Medium: 1})
}
if lt.ID != task.ID {
continue
}
found = true
if lt.Tokens.InputTokens != probe.Tokens.InputTokens ||
lt.Tokens.OutputTokens != probe.Tokens.OutputTokens ||
lt.GoalsMet != probe.GoalsMet || lt.GoalsTotal != probe.GoalsTotal {
t.Fatalf("list and detail metrics disagree: list=%+v/%d detail=%+v/%d",
lt.Tokens, lt.GoalsMet, probe.Tokens, probe.GoalsTotal)
lt.GoalsMet != probe.GoalsMet || lt.GoalsTotal != probe.GoalsTotal ||
lt.InFlight != probe.InFlight || lt.Findings != probe.Findings {
t.Fatalf("list and detail metrics disagree: list=%+v detail=%+v", lt, probe)
}
}
if !found {
t.Fatalf("task %s missing from list response", task.ID)
if !found || !foundOther {
t.Fatalf("list misses a task or its metrics: task %s found=%v, task %s with its findings=%v", task.ID, found, other.ID, foundOther)
}
}
+75 -19
View File
@@ -317,10 +317,14 @@ func (r *taskLLMRuntime) Complete(ctx context.Context, req llm.CompletionRequest
}
func completeTaskLLM(ctx context.Context, taskID string, req llm.CompletionRequest, hooks taskLLMStreamHooks) (llm.Message, string, llm.Usage, error) {
// total is the usage of the logical call: the sum of every physical attempt
// (same-provider retries and profile failover). Each attempt is already billed
// once by the recorder below; this sum only feeds the caller's accounting.
var total llm.Usage
for {
selection, err := hooks.current(ctx)
if err != nil {
return llm.Message{}, "", llm.Usage{}, err
return llm.Message{}, "", total, err
}
var (
msg llm.Message
@@ -333,13 +337,14 @@ func completeTaskLLM(ctx context.Context, taskID string, req llm.CompletionReque
retries, backoffOf := sameProviderRetryPolicy(selection.retry)
for attempt := 0; ; attempt++ {
msg, sr, usage, callErr = selection.provider.Complete(ctx, req)
total.Add(usage)
if callErr != nil && ctx.Err() == nil &&
attempt < retries && isRetryableStreamError(callErr) {
backoff := backoffOf(attempt)
log.Printf("[task-llm] task %s non-streaming call failed, retrying on the same provider after %v (%d/%d): %v",
taskID, backoff, attempt+1, retries, callErr)
if sleepCtx(ctx, backoff) {
break // ctx cancelled during backoff → stop retrying
return llm.Message{}, "", total, ctx.Err() // cancelled during backoff → stop
}
continue
}
@@ -347,7 +352,7 @@ func completeTaskLLM(ctx context.Context, taskID string, req llm.CompletionReque
}
if callErr == nil {
resetLLMFailStreak(taskID, selection.profileID)
return msg, sr, usage, nil
return msg, sr, total, nil
}
// Non-quota failures on a chain profile escalate after a consecutive
// streak (#99): same advance path as quota exhaust, truthful reason.
@@ -356,31 +361,31 @@ func completeTaskLLM(ctx context.Context, taskID string, req llm.CompletionReque
cause := fmt.Errorf("profile #%d failed %d consecutive calls (last: %w)", selection.profileID, llmFailStreakThreshold, callErr)
transition, markErr := hooks.exhaust(selection, cause)
if markErr != nil {
return llm.Message{}, "", llm.Usage{}, fmt.Errorf("mark profile exhausted after %v: %w", callErr, markErr)
return llm.Message{}, "", total, fmt.Errorf("mark profile exhausted after %v: %w", callErr, markErr)
}
if transition.Advanced && !transition.Stale && hooks.transition != nil {
hooks.transition(selection, transition, callErr)
}
if !transition.Stale && transition.NextProfileID == nil {
return llm.Message{}, "", llm.Usage{}, &taskLLMError{taskID: taskID, chainExhausted: transition.ChainExhausted, cause: callErr}
return llm.Message{}, "", total, &taskLLMError{taskID: taskID, chainExhausted: transition.ChainExhausted, cause: callErr}
}
continue // replay the same logical request on the next profile
}
return llm.Message{}, "", llm.Usage{}, callErr
return llm.Message{}, "", total, callErr
}
// profileID=0: the explicit chain was cleared; non-quota error: pass through. Neither changes the task failover state.
if selection.profileID == 0 || !isQuotaExhaustedError(callErr) {
return llm.Message{}, "", llm.Usage{}, callErr
return llm.Message{}, "", total, callErr
}
transition, markErr := hooks.exhaust(selection, callErr)
if markErr != nil {
return llm.Message{}, "", llm.Usage{}, fmt.Errorf("mark profile quota exhausted after %v: %w", callErr, markErr)
return llm.Message{}, "", total, fmt.Errorf("mark profile quota exhausted after %v: %w", callErr, markErr)
}
if transition.Advanced && !transition.Stale && hooks.transition != nil {
hooks.transition(selection, transition, callErr)
}
if !transition.Stale && transition.NextProfileID == nil {
return llm.Message{}, "", llm.Usage{}, &taskLLMError{taskID: taskID, chainExhausted: transition.ChainExhausted, cause: callErr}
return llm.Message{}, "", total, &taskLLMError{taskID: taskID, chainExhausted: transition.ChainExhausted, cause: callErr}
}
// No output has been delivered to the caller, so replaying the same logical request on the next profile is safe.
}
@@ -397,6 +402,35 @@ func streamTaskLLM(ctx context.Context, taskID string, req llm.CompletionRequest
committed := false
var pending []llm.StreamEvent
var streamErr error
// The caller's llm.Accumulator sums usage across the whole logical call
// (every attempt and profile): message_start is added, message_delta is
// folded with MergeEcho. Folding one attempt's echo against the sum of
// earlier attempts would lose input/cache tokens, so each attempt's echoes
// are normalized here against that attempt alone and only the increment
// is passed on, as a message_start. The recorder below already billed the
// physical attempt; this only shapes what the caller adds up.
var attemptUsage llm.Usage
forward := func(event llm.StreamEvent) bool {
switch event.Type {
case llm.SEMessageStart:
attemptUsage.Add(event.Usage)
case llm.SEMessageDelta:
next := attemptUsage
next.MergeEcho(event.Usage)
inc := llm.Usage{
InputTokens: next.InputTokens - attemptUsage.InputTokens,
OutputTokens: next.OutputTokens - attemptUsage.OutputTokens,
CacheReadTokens: next.CacheReadTokens - attemptUsage.CacheReadTokens,
CacheWriteTokens: next.CacheWriteTokens - attemptUsage.CacheWriteTokens,
}
attemptUsage = next
if inc != (llm.Usage{}) && !yield(llm.StreamEvent{Type: llm.SEMessageStart, Usage: inc}, nil) {
return false
}
event.Usage = llm.Usage{}
}
return yield(event, nil)
}
retries, backoffOf := sameProviderRetryPolicy(selection.retry)
// Same-provider safe-window retry: a transient failure before committed
// (nothing delivered to the caller yet) can be replayed verbatim without
@@ -405,6 +439,7 @@ func streamTaskLLM(ctx context.Context, taskID string, req llm.CompletionRequest
committed = false
pending = nil
streamErr = nil
attemptUsage = llm.Usage{}
for event, err := range selection.provider.Stream(ctx, req) {
if err != nil {
streamErr = err
@@ -416,24 +451,34 @@ func streamTaskLLM(ctx context.Context, taskID string, req llm.CompletionRequest
}
if !committed {
for _, buffered := range pending {
if !yield(buffered, nil) {
if !forward(buffered) {
return
}
}
pending = nil
committed = true
}
if !yield(event, nil) {
if !forward(event) {
return
}
}
if streamErr != nil && !committed {
// A failed uncommitted attempt delivers none of its events (they are
// never replayed), but the usage it reported survives as one
// message_start so the logical call still counts it.
if u := pendingUsage(pending); u != (llm.Usage{}) && !yield(llm.StreamEvent{Type: llm.SEMessageStart, Usage: u}, nil) {
return
}
pending = nil
}
if streamErr != nil && !committed && ctx.Err() == nil &&
attempt < retries && isRetryableStreamError(streamErr) {
backoff := backoffOf(attempt)
log.Printf("[task-llm] task %s pre-commit stream failure, retrying on the same provider after %v (%d/%d): %v",
taskID, backoff, attempt+1, retries, streamErr)
if sleepCtx(ctx, backoff) {
break // ctx cancelled during backoff → stop retrying
yield(llm.StreamEvent{}, ctx.Err()) // cancelled during backoff → stop
return
}
continue
}
@@ -442,7 +487,7 @@ func streamTaskLLM(ctx context.Context, taskID string, req llm.CompletionRequest
if streamErr == nil {
resetLLMFailStreak(taskID, selection.profileID)
for _, buffered := range pending {
if !yield(buffered, nil) {
if !forward(buffered) {
return
}
}
@@ -474,11 +519,6 @@ func streamTaskLLM(ctx context.Context, taskID string, req llm.CompletionRequest
// bundle was still in use. Agent/global fallback errors follow the legacy
// behavior and never mutate task failover state.
if selection.profileID == 0 || !isQuotaExhaustedError(streamErr) {
for _, buffered := range pending {
if !yield(buffered, nil) {
return
}
}
yield(llm.StreamEvent{}, streamErr)
return
}
@@ -508,9 +548,25 @@ func streamTaskLLM(ctx context.Context, taskID string, req llm.CompletionRequest
}
}
// pendingUsage is the usage the buffered (uncommitted) events of one attempt
// reported. Only message_start can be buffered: message_delta commits.
func pendingUsage(pending []llm.StreamEvent) llm.Usage {
var u llm.Usage
for _, ev := range pending {
if ev.Type == llm.SEMessageStart {
u.Add(ev.Usage)
}
}
return u
}
// streamEventCommitsOutput reports whether delivering event makes the attempt
// unreplayable. Signed and redacted thinking and tool-use signatures are opaque
// output the model bound to this exact response, so they commit like text.
func streamEventCommitsOutput(event llm.StreamEvent) bool {
switch event.Type {
case llm.SETextDelta, llm.SEThinkingDelta, llm.SEToolInputJSON:
case llm.SETextDelta, llm.SEThinkingDelta, llm.SEToolInputJSON,
llm.SEThinkingSignature, llm.SERedactedThinking, llm.SEToolUseSignature:
return event.Text != ""
case llm.SEToolUseStart, llm.SEMessageDelta, llm.SEMessageStop:
return true
-1
View File
@@ -77,4 +77,3 @@ func TestEscalationAdvancesAfterConsecutiveFailures(t *testing.T) {
t.Fatalf("exhaust calls=%d want 2", exhaustCalls)
}
}
+286
View File
@@ -0,0 +1,286 @@
package server
import (
"context"
"errors"
"fmt"
"iter"
"testing"
"time"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/llmrec"
"github.com/Autumn-27/norma/llm"
)
// Usage reported by one failed attempt, and by a successful one. A stream
// reports the input side in message_start and echoes it, with the output, in
// message_delta; the atomic path returns the attempt's normalized usage.
var (
failedAttemptUsage = llm.Usage{InputTokens: 11, OutputTokens: 2, CacheReadTokens: 3, CacheWriteTokens: 2}
okStartUsage = llm.Usage{InputTokens: 13, CacheReadTokens: 3, CacheWriteTokens: 2}
okDeltaUsage = llm.Usage{InputTokens: 17, OutputTokens: 5, CacheReadTokens: 4, CacheWriteTokens: 2}
okAttemptUsage = llm.Usage{InputTokens: 17, OutputTokens: 5, CacheReadTokens: 4, CacheWriteTokens: 2}
)
// usageAttemptProvider fails its first fail attempts (always, when fail < 0)
// after reporting failedAttemptUsage, then answers "done".
type usageAttemptProvider struct {
fail int
failErr error
onFail func()
// extra events a failed stream attempt emits before its error.
failEvents []llm.StreamEvent
calls int
}
func (p *usageAttemptProvider) failing() bool {
p.calls++
failed := p.fail < 0 || p.calls <= p.fail
if failed && p.onFail != nil {
p.onFail()
}
return failed
}
func (p *usageAttemptProvider) Complete(context.Context, llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
if p.failing() {
return llm.Message{}, "", failedAttemptUsage, p.failErr
}
return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{llm.TextBlock("done")}}, "end_turn", okAttemptUsage, nil
}
func (p *usageAttemptProvider) Stream(context.Context, llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
return func(yield func(llm.StreamEvent, error) bool) {
if p.failing() {
events := append([]llm.StreamEvent{{Type: llm.SEMessageStart, Usage: failedAttemptUsage}}, p.failEvents...)
for _, ev := range events {
if !yield(ev, nil) {
return
}
}
yield(llm.StreamEvent{}, p.failErr)
return
}
for _, ev := range []llm.StreamEvent{
{Type: llm.SEMessageStart, Usage: okStartUsage},
{Type: llm.SETextDelta, Text: "done"},
{Type: llm.SEMessageDelta, StopReason: "end_turn", Usage: okDeltaUsage},
{Type: llm.SEMessageStop},
} {
if !yield(ev, nil) {
return
}
}
}
}
// logicalResult is what the SDK sees for one logical call.
type logicalResult struct {
usage llm.Usage
text string
events []llm.StreamEvent
err error
}
func runLogicalCall(ctx context.Context, stream bool, taskID string, hooks taskLLMStreamHooks) logicalResult {
if !stream {
msg, _, usage, err := completeTaskLLM(ctx, taskID, llm.CompletionRequest{}, hooks)
return logicalResult{usage: usage, text: msg.Text(), err: err}
}
var r logicalResult
acc := llm.NewAccumulator()
for ev, err := range streamTaskLLM(ctx, taskID, llm.CompletionRequest{}, hooks) {
if err != nil {
r.err = err
break
}
r.events = append(r.events, ev)
acc.Add(ev)
}
r.usage, r.text = acc.Usage, acc.Message().Text()
return r
}
func singleProfileHooks(p llm.Provider, retry agent.RetryConfig) taskLLMStreamHooks {
return taskLLMStreamHooks{
current: func(context.Context) (taskLLMSelection, error) {
return taskLLMSelection{provider: p, retry: retry}, nil
},
exhaust: func(taskLLMSelection, error) (db.TaskLLMTransition, error) {
return db.TaskLLMTransition{}, nil
},
}
}
func sumUsage(us ...llm.Usage) llm.Usage {
var t llm.Usage
for _, u := range us {
t.Add(u)
}
return t
}
var usageModes = []struct {
name string
stream bool
}{{"atomic", false}, {"stream", true}}
func TestTaskLLMRetrySumsAttemptUsage(t *testing.T) {
for _, m := range usageModes {
t.Run(m.name, func(t *testing.T) {
p := &usageAttemptProvider{fail: 1, failErr: errors.New("connection reset by peer")}
r := runLogicalCall(t.Context(), m.stream, "p12-retry", singleProfileHooks(p, agent.RetryConfig{StreamAttempts: 1, StreamInterval: time.Millisecond}))
if r.err != nil {
t.Fatal(r.err)
}
want := sumUsage(failedAttemptUsage, okAttemptUsage) // {28 7 7 4}
if r.usage != want || p.calls != 2 || r.text != "done" {
t.Fatalf("usage=%+v want %+v, calls=%d, text=%q", r.usage, want, p.calls, r.text)
}
})
}
}
func TestTaskLLMExhaustedRetriesReturnSummedUsage(t *testing.T) {
for _, m := range usageModes {
t.Run(m.name, func(t *testing.T) {
p := &usageAttemptProvider{fail: -1, failErr: errors.New("connection reset by peer")}
r := runLogicalCall(t.Context(), m.stream, "p12-exhausted", singleProfileHooks(p, agent.RetryConfig{StreamAttempts: 1, StreamInterval: time.Millisecond}))
if r.err == nil || r.err.Error() != "connection reset by peer" {
t.Fatalf("err=%v", r.err)
}
want := sumUsage(failedAttemptUsage, failedAttemptUsage)
if r.usage != want || p.calls != 2 {
t.Fatalf("usage=%+v want %+v, calls=%d", r.usage, want, p.calls)
}
})
}
}
func TestTaskLLMProfileFailoverSumsUsage(t *testing.T) {
for _, m := range usageModes {
t.Run(m.name, func(t *testing.T) {
first := &usageAttemptProvider{fail: -1, failErr: errors.New("openai: status 402: insufficient_quota")}
second := &usageAttemptProvider{}
active := int64(11)
hooks := taskLLMStreamHooks{
current: func(context.Context) (taskLLMSelection, error) {
if active == 11 {
return taskLLMSelection{profileID: 11, provider: first}, nil
}
return taskLLMSelection{profileID: 22, provider: second}, nil
},
exhaust: func(taskLLMSelection, error) (db.TaskLLMTransition, error) {
active = 22
next := active
return db.TaskLLMTransition{PreviousProfileID: 11, NextProfileID: &next, Advanced: true}, nil
},
}
r := runLogicalCall(t.Context(), m.stream, "p12-failover-"+m.name, hooks)
if r.err != nil {
t.Fatal(r.err)
}
want := sumUsage(failedAttemptUsage, okAttemptUsage)
if r.usage != want || first.calls != 1 || second.calls != 1 || r.text != "done" {
t.Fatalf("usage=%+v want %+v, calls first=%d second=%d, text=%q", r.usage, want, first.calls, second.calls, r.text)
}
})
}
}
func TestTaskLLMCancelDuringBackoffStops(t *testing.T) {
for _, m := range usageModes {
t.Run(m.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
p := &usageAttemptProvider{fail: -1, failErr: errors.New("connection reset by peer")}
// Cancel once the retry is waiting out its (long) backoff.
p.onFail = func() { time.AfterFunc(20*time.Millisecond, cancel) }
r := runLogicalCall(ctx, m.stream, "p12-cancel", singleProfileHooks(p, agent.RetryConfig{StreamAttempts: 2, StreamInterval: time.Minute}))
if !errors.Is(r.err, context.Canceled) {
t.Fatalf("err=%v, want context.Canceled", r.err)
}
if p.calls != 1 || r.usage != failedAttemptUsage {
t.Fatalf("calls=%d usage=%+v", p.calls, r.usage)
}
})
}
}
func TestTaskLLMUncommittedStreamAttemptKeepsUsageWithoutReplay(t *testing.T) {
p := &usageAttemptProvider{
fail: 1,
failErr: errors.New("anthropic: overloaded_error"),
// Buffered, non-committing events of the failed attempt.
failEvents: []llm.StreamEvent{{Type: llm.SEThinkingDelta}, {Type: llm.SEToolInputJSON}},
}
r := runLogicalCall(t.Context(), true, "p12-uncommitted", singleProfileHooks(p, agent.RetryConfig{StreamAttempts: 1, StreamInterval: time.Millisecond}))
if r.err != nil {
t.Fatal(r.err)
}
if want := sumUsage(failedAttemptUsage, okAttemptUsage); r.usage != want {
t.Fatalf("usage=%+v want %+v", r.usage, want)
}
if r.text != "done" {
t.Fatalf("text=%q, want the second attempt's only", r.text)
}
// The failed attempt contributes one usage-only message_start, nothing else.
if len(r.events) == 0 || r.events[0].Type != llm.SEMessageStart || r.events[0].Usage != failedAttemptUsage {
t.Fatalf("first event %+v", r.events)
}
for _, ev := range r.events {
if ev.Type == llm.SEThinkingDelta || ev.Type == llm.SEToolInputJSON {
t.Fatalf("buffered event of the failed attempt was delivered: %+v", r.events)
}
}
}
func TestTaskLLMOpaqueThinkingCommitsStream(t *testing.T) {
defer withZeroRetryBackoff()()
for _, kind := range []llm.StreamEventType{llm.SEThinkingSignature, llm.SERedactedThinking, llm.SEToolUseSignature} {
t.Run(string(kind), func(t *testing.T) {
if !streamEventCommitsOutput(llm.StreamEvent{Type: kind, Text: "opaque"}) {
t.Fatalf("%s does not commit", kind)
}
// A retryable failure after the opaque block is surfaced, not replayed.
p := &scriptedLLMProvider{events: []llm.StreamEvent{{Type: kind, Text: "opaque"}}, err: errors.New("connection reset by peer")}
events, err := collectTaskLLMStream(streamTaskLLM(t.Context(), "p12-opaque", llm.CompletionRequest{}, singleProfileHooks(p, agent.RetryConfig{})))
if err == nil || p.calls != 1 || len(events) != 1 || events[0].Type != kind {
t.Fatalf("calls=%d events=%+v err=%v", p.calls, events, err)
}
})
}
}
// The recorder below the task router writes one llm_usage row per physical
// attempt; summing the attempts for the logical call adds no row.
func TestTaskLLMRetryKeepsOneLedgerRowPerPhysicalCall(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
defer m.Close()
pg := m.PG()
for _, mode := range usageModes {
t.Run(mode.name, func(t *testing.T) {
taskID := fmt.Sprintf("p12-ledger-%s-%d", mode.name, time.Now().UnixNano())
t.Cleanup(func() { _, _ = pg.Exec(`DELETE FROM llm_usage WHERE task_id=$1`, taskID) })
p := &usageAttemptProvider{fail: 1, failErr: errors.New("connection reset by peer")}
rec := llmrec.Wrap(p, pg, "test-model", "test-profile", "", "", func() bool { return false })
ctx := llmrec.WithTaskID(t.Context(), taskID)
r := runLogicalCall(ctx, mode.stream, taskID, singleProfileHooks(rec, agent.RetryConfig{StreamAttempts: 1, StreamInterval: time.Millisecond}))
if r.err != nil {
t.Fatal(r.err)
}
var rows, failed int
if err := pg.QueryRow(`SELECT count(*), count(*) FILTER (WHERE status='error') FROM llm_usage WHERE task_id=$1`, taskID).Scan(&rows, &failed); err != nil {
t.Fatal(err)
}
if rows != 2 || failed != 1 || p.calls != 2 {
t.Fatalf("llm_usage rows=%d (error %d), physical calls=%d; want 2 rows, 1 error", rows, failed, p.calls)
}
})
}
}
+1 -1
View File
@@ -5,9 +5,9 @@ import (
"encoding/json"
"testing"
actool "github.com/Autumn-27/norma/tool"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
actool "github.com/Autumn-27/norma/tool"
)
func names(tools []actool.CoreTool) map[string]actool.CoreTool {
+4 -1
View File
@@ -32,8 +32,11 @@ func (s SideQuestionService) Answer(ctx context.Context, req llm.CompletionReque
out.Text += ev.Text
case llm.SEToolUseStart:
out.ToolUse = true
case llm.SEMessageStart, llm.SEMessageDelta:
case llm.SEMessageStart:
out.Usage.Add(ev.Usage)
case llm.SEMessageDelta:
// message_delta may echo message_start's input side: MergeEcho, not Add.
out.Usage.MergeEcho(ev.Usage)
case llm.SEMessageStop:
complete = true
}
+52
View File
@@ -0,0 +1,52 @@
package sidequestion
import (
"context"
"iter"
"testing"
"github.com/Autumn-27/norma/llm"
)
// A streamed side answer counts message_delta's echo of the input side once.
func TestAnswerStreamUsageEchoCountedOnce(t *testing.T) {
for _, tc := range []struct {
name string
start, delta llm.Usage
want llm.Usage
}{
{"full_echo",
llm.Usage{InputTokens: 23, CacheReadTokens: 5, CacheWriteTokens: 3},
llm.Usage{InputTokens: 23, CacheReadTokens: 5, CacheWriteTokens: 3, OutputTokens: 5},
llm.Usage{InputTokens: 23, OutputTokens: 5, CacheReadTokens: 5, CacheWriteTokens: 3}},
{"partial_echo",
llm.Usage{InputTokens: 740, CacheReadTokens: 200, CacheWriteTokens: 40},
llm.Usage{InputTokens: 500, OutputTokens: 77},
llm.Usage{InputTokens: 740, OutputTokens: 77, CacheReadTokens: 200, CacheWriteTokens: 40}},
{"no_echo",
llm.Usage{InputTokens: 23, CacheReadTokens: 5, CacheWriteTokens: 3},
llm.Usage{OutputTokens: 5},
llm.Usage{InputTokens: 23, OutputTokens: 5, CacheReadTokens: 5, CacheWriteTokens: 3}},
} {
t.Run(tc.name, func(t *testing.T) {
p := fakeProvider{stream: func(context.Context, llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
return func(yield func(llm.StreamEvent, error) bool) {
for _, ev := range []llm.StreamEvent{
{Type: llm.SEMessageStart, Usage: tc.start},
{Type: llm.SETextDelta, Text: "answer"},
{Type: llm.SEMessageDelta, StopReason: "end_turn", Usage: tc.delta},
{Type: llm.SEMessageStop},
} {
if !yield(ev, nil) {
return
}
}
}
}}
out, err := (SideQuestionService{Provider: p}).Answer(t.Context(), llm.CompletionRequest{}, true, nil)
if err != nil || out.Text != "answer" || out.Usage != tc.want {
t.Fatalf("answer=%+v err=%v, want usage %+v", out, err, tc.want)
}
})
}
}
+588 -234
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -21,7 +21,6 @@
},
"dependencies": {
"@antv/g6": "^5.1.1",
"@antv/graphin": "^3.0.5",
"@base-ui/react": "^1.6.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
@@ -40,7 +39,7 @@
"geist": "^1.7.2",
"input-otp": "^1.4.2",
"lucide-react": "^1.22.0",
"next": "^16.2.9",
"next": "^16.4.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.6.0",
"react": "^19.2.7",
@@ -52,7 +51,6 @@
"react-resizable-panels": "^4.12.0",
"recharts": "^3.8.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.12.0",
"simple-icons": "^16.24.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
@@ -74,6 +72,7 @@
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"postcss": "^8.5.16",
"shadcn": "^4.12.0",
"tailwindcss": "^4.1.5",
"ts-node": "^10.9.2",
"tw-animate-css": "^1.4.0",