mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-15 04:30:55 +08:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d234e341fc | ||
|
|
4cde1a889f | ||
|
|
16ddfc7779 | ||
|
|
4f2c6ef789 | ||
|
|
d116517c64 | ||
|
|
6a5adf73fe | ||
|
|
3b5b2c3b88 | ||
|
|
0b59f4670b | ||
|
|
b789dd2eae | ||
|
|
e640c1b0fd | ||
|
|
f3a20ef2c6 | ||
|
|
34ece5937a | ||
|
|
8c3bae41f6 | ||
|
|
6109917957 | ||
|
|
e5202b1c34 | ||
|
|
e59db80173 | ||
|
|
1e3c858b4e | ||
|
|
f90589c03c |
+30
-4
@@ -18,7 +18,33 @@ ARTEX_LLM_MODEL=
|
||||
ARTEX_LLM_BASE_URL=
|
||||
ARTEX_LLM_PROXY=
|
||||
|
||||
# Untrusted-output boundary (optional). ARTEX_UNTRUSTED_BOUNDARY=1 frames every tool
|
||||
# output between nonce-tagged markers so the model treats it as data, not instructions.
|
||||
# Off by default; test it against a local target before enabling it in production.
|
||||
# ARTEX_UNTRUSTED_BOUNDARY=1
|
||||
# Untrusted-output boundary. Frames every tool output between nonce-tagged markers so
|
||||
# the model treats it as data, not instructions. On by default (unset or empty = on);
|
||||
# set ARTEX_UNTRUSTED_BOUNDARY=0 (or false/off/no) to disable it. Measured cost: about
|
||||
# 154 bytes per tool result plus one system note.
|
||||
# ARTEX_UNTRUSTED_BOUNDARY=0
|
||||
|
||||
# Noise profile of the requests ARTEX sends to targets (WebFetch, http custom tools, enrich
|
||||
# probe). Default off: no limit, requests unchanged. quiet = 2 req/s per host, burst 4,
|
||||
# 1 in flight; balanced = 5 req/s, burst 10, 2 in flight; aggressive = 20 req/s, burst 40,
|
||||
# 4 in flight. A profile also adds jitter, backs off on 429/503 and merges identical GETs
|
||||
# of one task per tool (short cache; WebFetch is paced only). Bash-launched tools are not throttled.
|
||||
# ARTEX_NOISE_PROFILE=balanced
|
||||
# User-Agent set on target requests that have none, only under a profile. Empty = unchanged.
|
||||
# ARTEX_TARGET_USER_AGENT=
|
||||
|
||||
# Run budgets (optional). Unset or 0 = unlimited, nothing changes. TASK_* caps a whole task
|
||||
# (planner rounds and all its workers); INTENT_* caps one worker run. Kept in memory: a
|
||||
# restart, or a revive of a finished task, starts a fresh budget.
|
||||
# Below 50000 tokens a task gets no worker at all (one worker costs about 50000 tokens by
|
||||
# default); from 50000 to about 70000 it typically gets one worker, then the forced wrap-up. Use a
|
||||
# budget well above 70000 for real exploration.
|
||||
# ARTEX_TASK_BUDGET_TOKENS=
|
||||
# ARTEX_TASK_BUDGET_TURNS=
|
||||
# ARTEX_INTENT_BUDGET_TOKENS=
|
||||
# ARTEX_INTENT_BUDGET_TURNS=
|
||||
|
||||
# Update banner (optional). ARTEX_UPDATE_CHECK=1 lets the backend check the public GitHub
|
||||
# releases API for a newer ARTEX release at most once per 24 h and show a dismissible banner.
|
||||
# Off by default: unset, no update request is made.
|
||||
# ARTEX_UPDATE_CHECK=1
|
||||
|
||||
@@ -63,7 +63,8 @@ jobs:
|
||||
# shellcheck is preinstalled on the ubuntu runners. -S warning: the scripts
|
||||
# currently have only info-level notes (intentional A && B || C chains in
|
||||
# update.sh, an unquoted glob in backup.sh's prune), so fail on warnings.
|
||||
- run: shellcheck -S warning install.sh update.sh start.sh scripts/backup.sh reset-password.sh
|
||||
- run: shellcheck -S warning install.sh update.sh start.sh scripts/backup.sh reset-password.sh scripts/extract-changelog-section.sh scripts/test-extract-changelog-section.sh
|
||||
- run: bash scripts/test-extract-changelog-section.sh
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -82,6 +82,7 @@ jobs:
|
||||
needs: binaries
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4 # CHANGELOG.md and the extractor script
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: pkg-*
|
||||
@@ -90,13 +91,24 @@ jobs:
|
||||
- name: Generate checksums
|
||||
working-directory: dist
|
||||
run: sha256sum *.zip > SHA256SUMS
|
||||
- name: Release body from CHANGELOG.md
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
version="${TAG#v}"
|
||||
if bash scripts/extract-changelog-section.sh "$version" CHANGELOG.md > "$RUNNER_TEMP/release-body.md"; then
|
||||
echo "Release body: CHANGELOG.md section [$version]"
|
||||
else
|
||||
echo "::warning::CHANGELOG.md has no section [$version]; using the generic release body"
|
||||
echo "Release $TAG. See CHANGELOG.md for the list of changes in this release." > "$RUNNER_TEMP/release-body.md"
|
||||
fi
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
dist/*.zip
|
||||
dist/SHA256SUMS
|
||||
generate_release_notes: false
|
||||
body: "See CHANGELOG.md for the list of changes in this release."
|
||||
body_path: ${{ runner.temp }}/release-body.md
|
||||
|
||||
# ④ Multi-arch image → push to Docker Hub (reuses the Linux binaries built by the
|
||||
# binaries job; the image only installs tools + ships the binary; arm64 only needs
|
||||
|
||||
@@ -183,6 +183,8 @@ Daily cron example (03:20, operator crontab):
|
||||
|
||||
> Upgrading only swaps the program and never touches data: the Postgres volume `pgdata`, `./data` (SQLite, task files, etc.), `./keys` (jwt.key), and `./skills` are all preserved. **No manual database migration is needed**: `artex` idempotently re-runs `schema.sql` on every start (with `ADD COLUMN` / `CREATE INDEX IF NOT EXISTS`), i.e. "restart equals migrate". It is still recommended to back up `./data` and the database before upgrading.
|
||||
|
||||
**Update banner (opt-in, off by default)**: start ARTEX with `ARTEX_UPDATE_CHECK=1` to have the backend ask the public GitHub releases API for the latest ARTEX tag, at most once per 24 hours (cached in memory; a failed check is not retried within that window). When the release is newer than the running version, the web UI shows a small dismissible strip linking to the Releases page. The request sends only the `User-Agent: artex-update-check` header. Unset, ARTEX makes no update request and shows no banner.
|
||||
|
||||
### Upgrading from a clone made before 2026-10-23
|
||||
|
||||
The repository history was rewritten that day to drop a personal email address from the commit metadata. A clone made before then cannot fast-forward, and `./update.sh` now stops instead of silently rebuilding stale code. Recover once, then upgrade normally:
|
||||
@@ -260,6 +262,12 @@ Optional: `ARTEX_LLM_PROVIDER` (anthropic | openai | openai-responses | ollama)
|
||||
|
||||
**Concurrency**: the number of work agents per task is configured in "System Settings" (default 3).
|
||||
|
||||
**Untrusted-output boundary (on by default)**: tool results for the worker, planner, chat and main agent are framed between nonce-tagged markers so the model treats them as data, not instructions. Unset or empty, the boundary is on; set `ARTEX_UNTRUSTED_BOUNDARY=0` (or `false` / `off` / `no`) to disable it. Measured cost: about 154 bytes per tool result plus one system note.
|
||||
|
||||
**Noise profile (off by default)**: `ARTEX_NOISE_PROFILE` paces the requests ARTEX itself sends to targets (WebFetch, http custom tools, the enrich probe). Unset or `off`, nothing changes: no limit, no cache. Set `quiet` (2 req/s per host, burst 4, 1 in flight), `balanced` (5 req/s, burst 10, 2 in flight) or `aggressive` (20 req/s, burst 40, 4 in flight), e.g. `ARTEX_NOISE_PROFILE=balanced` in `.env` or the environment. A profile also adds a small jitter, backs off on 429/503 (honouring `Retry-After`, bounded retries, GET/HEAD/OPTIONS only), and, for the http custom tools and the enrich probe, merges identical in-flight GETs of one task (per tool) and caches them briefly (60/30/10 s; never POST, PUT, PATCH or DELETE; WebFetch is paced only), and logs a per-host noise line each minute. `ARTEX_TARGET_USER_AGENT` sets a User-Agent on target requests that carry none, under a profile; empty keeps the current one. Tools launched through Bash are not paced yet.
|
||||
|
||||
**Run budgets (off by default)**: `ARTEX_TASK_BUDGET_TOKENS` and `ARTEX_TASK_BUDGET_TURNS` cap a task's total spend (its planner rounds and all its workers); `ARTEX_INTENT_BUDGET_TOKENS` and `ARTEX_INTENT_BUDGET_TURNS` cap one worker run. Unset or `0` means unlimited, and nothing changes. A worker is not started when the remaining task budget cannot cover its estimated cost (the average of the task's finished workers, else 50000 tokens and 10 turns). When the remaining budget reaches the wrap-up reserve (the larger of 5% and 20000 tokens, and 2 turns), the task enters a forced wrap-up: the planner writes a summary and the final goal judgment, and workers may only write back (`record_fact`, `insert_assets`, `report_finding`, `update_finding_report`). The task then ends `done` if its goals are met, otherwise `timeout`. Budgets are kept in memory: a restart, or a revive of a finished task, starts a fresh budget. Each planner round logs a `[budget]` line. Practical floor: below 50000 tokens a task gets no worker; from 50000 to about 70000 it typically gets one worker and then the wrap-up. Pick a budget well above 70000 tokens for real exploration.
|
||||
|
||||
**Common flags**: `./start.sh -addr :8787 -proxy :8788` (`-addr` frontend+API, `-proxy` traffic recording proxy). The startup script passes arguments through to `artex` verbatim.
|
||||
|
||||
### Reverse proxy deployment (HTTPS / only exposing 443)
|
||||
|
||||
@@ -60,6 +60,8 @@ func AugmentTools(ctx context.Context, agentKey string, base []actool.CoreTool)
|
||||
if ToolResolve != nil {
|
||||
out = ToolResolve(ctx, agentKey, out)
|
||||
}
|
||||
// Tool RAG (off by default): lexical projection of the bound list; only removes.
|
||||
out = applyToolRAG(ctx, agentKey, out, def.Deferred, bm25Score)
|
||||
out, def.FindingGuidance = findingWorkflowTools(agentKey, out)
|
||||
for i, t := range out {
|
||||
out[i] = guardPanic(t)
|
||||
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
)
|
||||
|
||||
// Hierarchical run budgets (off by default). Levels: task -> intent (one worker
|
||||
// run) -> planner round (counted inside the task). A child's effective budget is
|
||||
// min(its own budget, the parent's remaining above the wrap-up reserve). When the
|
||||
// task can no longer afford a worker, dispatch stops and the run settles through
|
||||
// one forced wrap-up planner round (same pattern as the task-timeout endgame).
|
||||
// Wall-clock stays in taskclock/endgame. Counting matches the llm_usage ledger:
|
||||
// one turn per metered call (one row), tokens = input + output; norma's Usage
|
||||
// already folds the cache buckets into InputTokens, so they are not added again.
|
||||
|
||||
// Unlimited is the cap of a dimension with no budget (env value 0 or unset).
|
||||
const Unlimited int64 = math.MaxInt64
|
||||
|
||||
const (
|
||||
BudgetMarginPercent = 5 // wrap-up reserve = max(5% of the task budget, floor)
|
||||
BudgetMarginMinTokens = 20_000 // token floor of the reserve
|
||||
BudgetMarginMinTurns = 2 // turn floor of the reserve
|
||||
DefaultWorkerEstimateTokens = 50_000 // one worker run, until a worker of this task finished
|
||||
DefaultWorkerEstimateTurns = 10
|
||||
)
|
||||
|
||||
// Budget caps tokens and turns; a dimension equal to Unlimited has no cap.
|
||||
type Budget struct{ Tokens, Turns int64 }
|
||||
|
||||
// Spend is what a level has consumed.
|
||||
type Spend struct{ Tokens, Turns int64 }
|
||||
|
||||
// NewBudget builds a budget from configured values: <=0 means Unlimited.
|
||||
func NewBudget(tokens, turns int64) Budget { return Budget{budgetCap(tokens), budgetCap(turns)} }
|
||||
|
||||
func budgetCap(n int64) int64 {
|
||||
if n <= 0 {
|
||||
return Unlimited
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// EffectiveBudget is the hierarchical calculator: per dimension, a child never
|
||||
// gets more than its parent has left.
|
||||
func EffectiveBudget(own, parentRemaining Budget) Budget {
|
||||
return Budget{min(own.Tokens, parentRemaining.Tokens), min(own.Turns, parentRemaining.Turns)}
|
||||
}
|
||||
|
||||
// Remaining is the budget left after s (never negative; Unlimited stays Unlimited).
|
||||
func (b Budget) Remaining(s Spend) Budget {
|
||||
return Budget{left(b.Tokens, s.Tokens), left(b.Turns, s.Turns)}
|
||||
}
|
||||
|
||||
func left(capacity, spent int64) int64 {
|
||||
if capacity == Unlimited {
|
||||
return Unlimited
|
||||
}
|
||||
return max(capacity-spent, 0)
|
||||
}
|
||||
|
||||
// WrapupMargin is the reserve kept for the forced wrap-up (0 when unlimited).
|
||||
func WrapupMargin(task Budget) Budget {
|
||||
m := func(capacity, floor int64) int64 {
|
||||
if capacity == Unlimited {
|
||||
return 0
|
||||
}
|
||||
return max(capacity*BudgetMarginPercent/100, floor)
|
||||
}
|
||||
return Budget{m(task.Tokens, BudgetMarginMinTokens), m(task.Turns, BudgetMarginMinTurns)}
|
||||
}
|
||||
|
||||
func addUsage(s Spend, u llm.Usage) Spend {
|
||||
return Spend{s.Tokens + int64(u.InputTokens+u.OutputTokens), s.Turns + 1}
|
||||
}
|
||||
|
||||
// BudgetsFromEnv reads ARTEX_TASK_BUDGET_{TOKENS,TURNS} and
|
||||
// ARTEX_INTENT_BUDGET_{TOKENS,TURNS}. Unset, 0 or invalid = Unlimited.
|
||||
func BudgetsFromEnv() (task, intent Budget) {
|
||||
env := func(k string) int64 {
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(os.Getenv(k)), 10, 64)
|
||||
return n
|
||||
}
|
||||
return NewBudget(env("ARTEX_TASK_BUDGET_TOKENS"), env("ARTEX_TASK_BUDGET_TURNS")),
|
||||
NewBudget(env("ARTEX_INTENT_BUDGET_TOKENS"), env("ARTEX_INTENT_BUDGET_TURNS"))
|
||||
}
|
||||
|
||||
// BudgetLedger meters one task, in memory (a process restart starts afresh).
|
||||
type BudgetLedger struct {
|
||||
task, intent Budget
|
||||
mu sync.Mutex
|
||||
spent Spend // planner rounds + worker runs
|
||||
workerRuns int64
|
||||
workerSpent Spend
|
||||
wrapup bool // the forced wrap-up has started
|
||||
}
|
||||
|
||||
// NewBudgetLedger returns nil when no budget is set: callers then change nothing.
|
||||
func NewBudgetLedger(task, intent Budget) *BudgetLedger {
|
||||
if task == NewBudget(0, 0) && intent == NewBudget(0, 0) {
|
||||
return nil
|
||||
}
|
||||
return &BudgetLedger{task: task, intent: intent}
|
||||
}
|
||||
|
||||
// Record meters one task-level call (the planner's own rounds).
|
||||
func (l *BudgetLedger) Record(u llm.Usage) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.spent = addUsage(l.spent, u)
|
||||
}
|
||||
|
||||
// BudgetState is one decision snapshot of the task level.
|
||||
type BudgetState struct {
|
||||
Task, Remaining, Margin Budget
|
||||
Spent, Estimate Spend
|
||||
AtMargin bool // remaining is at or under the wrap-up reserve
|
||||
Dispatch bool // a new worker may start
|
||||
}
|
||||
|
||||
// State reports the remaining budget and whether a worker may be dispatched:
|
||||
// not once the wrap-up started or the reserve is reached, and not when the
|
||||
// remaining budget is below one worker's estimated cost (the average of this
|
||||
// task's finished worker runs, else the defaults; never above the intent cap).
|
||||
func (l *BudgetLedger) State() BudgetState {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
rem, m := l.task.Remaining(l.spent), WrapupMargin(l.task)
|
||||
est := Spend{DefaultWorkerEstimateTokens, DefaultWorkerEstimateTurns}
|
||||
if l.workerRuns > 0 {
|
||||
est = Spend{l.workerSpent.Tokens / l.workerRuns, l.workerSpent.Turns / l.workerRuns}
|
||||
}
|
||||
est = Spend{min(est.Tokens, l.intent.Tokens), min(est.Turns, l.intent.Turns)}
|
||||
at := (rem.Tokens != Unlimited && rem.Tokens <= m.Tokens) || (rem.Turns != Unlimited && rem.Turns <= m.Turns)
|
||||
return BudgetState{Task: l.task, Remaining: rem, Margin: m, Spent: l.spent, Estimate: est, AtMargin: at,
|
||||
Dispatch: !l.wrapup && !at && rem.Tokens >= est.Tokens && rem.Turns >= est.Turns}
|
||||
}
|
||||
|
||||
// BeginWrapup marks the forced wrap-up; true only for the first caller, so the
|
||||
// wrap-up runs exactly once. Running workers see an exhausted budget from here on.
|
||||
func (l *BudgetLedger) BeginWrapup() bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
first := !l.wrapup
|
||||
l.wrapup = true
|
||||
return first
|
||||
}
|
||||
|
||||
// Line is the one-line remaining budget shown to the planner and logged.
|
||||
func (s BudgetState) Line() string {
|
||||
of := func(rem, capacity int64) string {
|
||||
if capacity == Unlimited {
|
||||
return "unlimited"
|
||||
}
|
||||
return fmt.Sprintf("%d of %d", rem, capacity)
|
||||
}
|
||||
return fmt.Sprintf("[Task budget] remaining: tokens %s, turns %s (wrap-up reserve: %d tokens, %d turns)",
|
||||
of(s.Remaining.Tokens, s.Task.Tokens), of(s.Remaining.Turns, s.Task.Turns), s.Margin.Tokens, s.Margin.Turns)
|
||||
}
|
||||
|
||||
// IntentMeter meters one worker run against min(intent budget, task remaining
|
||||
// above the reserve).
|
||||
type IntentMeter struct {
|
||||
l *BudgetLedger
|
||||
spent Spend // guarded by l.mu
|
||||
}
|
||||
|
||||
// StartIntent opens the meter of one worker run.
|
||||
func (l *BudgetLedger) StartIntent() *IntentMeter { return &IntentMeter{l: l} }
|
||||
|
||||
// Record meters one worker call into the intent and the task.
|
||||
func (m *IntentMeter) Record(u llm.Usage) {
|
||||
m.l.mu.Lock()
|
||||
defer m.l.mu.Unlock()
|
||||
m.spent, m.l.spent = addUsage(m.spent, u), addUsage(m.l.spent, u)
|
||||
}
|
||||
|
||||
// Remaining is the run's effective remaining budget; zero once the wrap-up started.
|
||||
func (m *IntentMeter) Remaining() Budget {
|
||||
l := m.l
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
parent := Budget{}
|
||||
if !l.wrapup {
|
||||
rem, res := l.task.Remaining(l.spent), WrapupMargin(l.task)
|
||||
parent = Budget{left(rem.Tokens, res.Tokens), left(rem.Turns, res.Turns)}
|
||||
}
|
||||
return EffectiveBudget(l.intent.Remaining(m.spent), parent)
|
||||
}
|
||||
|
||||
// Exhausted reports whether the run has no budget left in some dimension.
|
||||
func (m *IntentMeter) Exhausted() bool {
|
||||
r := m.Remaining()
|
||||
return r.Tokens == 0 || r.Turns == 0
|
||||
}
|
||||
|
||||
// Finish folds the run into the task's per-worker average.
|
||||
func (m *IntentMeter) Finish() {
|
||||
m.l.mu.Lock()
|
||||
defer m.l.mu.Unlock()
|
||||
m.l.workerRuns++
|
||||
m.l.workerSpent = Spend{m.l.workerSpent.Tokens + m.spent.Tokens, m.l.workerSpent.Turns + m.spent.Turns}
|
||||
}
|
||||
|
||||
// BudgetWrapupBlocks reports whether a worker tool is refused once its budget is
|
||||
// exhausted: all but the write-back tools, so the run records facts and stops.
|
||||
func BudgetWrapupBlocks(tool string) bool {
|
||||
return tool != "record_fact" && tool != "insert_assets" && tool != "report_finding" && tool != "update_finding_report"
|
||||
}
|
||||
|
||||
// WorkerBudgetWrapup is returned in place of a refused tool call.
|
||||
const WorkerBudgetWrapup = "[Budget reached] This intent's token/turn budget is used up; the tool call was not executed. Do not run any more commands or probes. Write back what you identified but have not persisted yet (insert_assets, record_fact, report_finding), then, in a single standalone sentence of plain text, summarize what you did and your key conclusions."
|
||||
|
||||
const plannerBudgetWrapup = "**The task's token/turn budget is exhausted and the task is about to end** (a normal end, not an error). Based on the current facts and findings, make the final goal judgment: call prove_goal for goals proven met by evidence. **Do not generate any new intents** (they will not be executed). Then, in a single standalone sentence of plain text, summarize where the task stands."
|
||||
|
||||
// BudgetRound carries one planner round's budget on ctx (zero = no budget).
|
||||
type BudgetRound struct {
|
||||
Line string // one-line remaining budget for the planner context
|
||||
WrapUp bool // the forced wrap-up round
|
||||
}
|
||||
|
||||
type budgetRoundKey struct{}
|
||||
type intentMeterKey struct{}
|
||||
|
||||
// WithBudgetRound attaches a planner round's budget to ctx.
|
||||
func WithBudgetRound(ctx context.Context, r BudgetRound) context.Context {
|
||||
return context.WithValue(ctx, budgetRoundKey{}, r)
|
||||
}
|
||||
|
||||
// WithIntentMeter attaches a worker run's meter to ctx (turn clamp).
|
||||
func WithIntentMeter(ctx context.Context, m *IntentMeter) context.Context {
|
||||
return context.WithValue(ctx, intentMeterKey{}, m)
|
||||
}
|
||||
|
||||
// plannerBudgetBlock is the code-owned budget text appended to the planner's
|
||||
// situational block (same pattern as the endgame words); empty without a budget.
|
||||
func plannerBudgetBlock(ctx context.Context) string {
|
||||
r, _ := ctx.Value(budgetRoundKey{}).(BudgetRound)
|
||||
s := ""
|
||||
if r.Line != "" {
|
||||
s += "\n\n" + r.Line
|
||||
}
|
||||
if r.WrapUp {
|
||||
s += "\n\n[Task budget wrap-up (special instruction for this round, overriding the normal planning flow above)]: " + plannerBudgetWrapup
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// turnCap is the worker's MaxTurns for this run: its own, clamped by the
|
||||
// intent's effective remaining turns (never 0, which harness reads as unlimited).
|
||||
func (w *Worker) turnCap(ctx context.Context) int {
|
||||
m, _ := ctx.Value(intentMeterKey{}).(*IntentMeter)
|
||||
if m == nil {
|
||||
return w.maxTurns
|
||||
}
|
||||
rem := m.Remaining().Turns
|
||||
if rem == Unlimited {
|
||||
return w.maxTurns
|
||||
}
|
||||
if c := int(max(rem, 1)); w.maxTurns <= 0 || c < w.maxTurns {
|
||||
return c
|
||||
}
|
||||
return w.maxTurns
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
)
|
||||
|
||||
// spend records n tokens as one task-level call; finishWorker records one
|
||||
// finished worker run of n tokens (it feeds the per-worker estimate).
|
||||
func spend(l *BudgetLedger, n int) { l.Record(llm.Usage{InputTokens: n}) }
|
||||
|
||||
func finishWorker(l *BudgetLedger, n int) {
|
||||
m := l.StartIntent()
|
||||
m.Record(llm.Usage{OutputTokens: n})
|
||||
m.Finish()
|
||||
}
|
||||
|
||||
func TestEffectiveBudgetNeverExceedsParent(t *testing.T) {
|
||||
for _, c := range []struct{ own, parent, want Budget }{
|
||||
{NewBudget(50, 5), NewBudget(100, 10), Budget{50, 5}},
|
||||
{NewBudget(200, 20), NewBudget(100, 10), Budget{100, 10}},
|
||||
{NewBudget(0, 0), NewBudget(100, 10), Budget{100, 10}}, // own unlimited
|
||||
{NewBudget(70, 7), NewBudget(0, 0), Budget{70, 7}}, // parent unlimited
|
||||
{NewBudget(0, 0), NewBudget(0, 0), Budget{Unlimited, Unlimited}},
|
||||
{NewBudget(70, 7), Budget{0, 0}, Budget{0, 0}}, // parent exhausted
|
||||
} {
|
||||
if got := EffectiveBudget(c.own, c.parent); got != c.want || got.Tokens > c.parent.Tokens || got.Turns > c.parent.Turns {
|
||||
t.Fatalf("EffectiveBudget(%+v, %+v) = %+v, want %+v", c.own, c.parent, got, c.want)
|
||||
}
|
||||
}
|
||||
// Through the meter: intent 100k is cut to the task's remaining above the
|
||||
// reserve (task 100k, reserve 20k, 70k spent → 10k); intent turns clamp MaxTurns.
|
||||
l := NewBudgetLedger(NewBudget(100_000, 0), NewBudget(100_000, 5))
|
||||
spend(l, 70_000)
|
||||
m := l.StartIntent()
|
||||
if got := m.Remaining(); got != (Budget{10_000, 5}) {
|
||||
t.Fatalf("intent remaining = %+v, want {10000 5}", got)
|
||||
}
|
||||
if got := (&Worker{maxTurns: 40}).turnCap(WithIntentMeter(context.Background(), m)); got != 5 {
|
||||
t.Fatalf("worker MaxTurns = %d, want the intent's 5", got)
|
||||
}
|
||||
if m.Record(llm.Usage{InputTokens: 10_000}); !m.Exhausted() {
|
||||
t.Fatal("intent not exhausted once the task's remaining above the reserve is spent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapupMarginTriggersOnceAndStopsDispatch(t *testing.T) {
|
||||
if a, b := WrapupMargin(NewBudget(1_000_000, 100)), WrapupMargin(NewBudget(100_000, 10)); a != (Budget{50_000, 5}) || b != (Budget{20_000, 2}) {
|
||||
t.Fatalf("margins %+v %+v, want 5%% then the floors", a, b)
|
||||
}
|
||||
l := NewBudgetLedger(NewBudget(100_000, 0), NewBudget(0, 0))
|
||||
finishWorker(l, 10) // tiny estimate: only the reserve can stop dispatch here
|
||||
spend(l, 100_000-10-20_001)
|
||||
if st := l.State(); st.AtMargin || !st.Dispatch {
|
||||
t.Fatalf("one token above the reserve: %+v, want dispatch", st)
|
||||
}
|
||||
spend(l, 1) // remaining == reserve
|
||||
st := l.State()
|
||||
if !st.AtMargin || st.Dispatch {
|
||||
t.Fatalf("at the reserve: %+v, want AtMargin and no dispatch", st)
|
||||
}
|
||||
running, wrapups := l.StartIntent(), 0
|
||||
for i := 0; i < 3; i++ { // several planner wake-ups at the reserve
|
||||
if l.BeginWrapup() {
|
||||
wrapups++
|
||||
}
|
||||
}
|
||||
if wrapups != 1 || l.State().Dispatch {
|
||||
t.Fatalf("wrap-up started %d times (want 1), dispatch=%v", wrapups, l.State().Dispatch)
|
||||
}
|
||||
if !running.Exhausted() || !BudgetWrapupBlocks("Bash") || BudgetWrapupBlocks("record_fact") {
|
||||
t.Fatal("after the wrap-up a running worker must be held to its write-back tools")
|
||||
}
|
||||
block := plannerBudgetBlock(WithBudgetRound(context.Background(), BudgetRound{Line: st.Line(), WrapUp: true}))
|
||||
if strings.Count(block, plannerBudgetWrapup) != 1 || !strings.Contains(block, "[Task budget] remaining: tokens 20000 of 100000") {
|
||||
t.Fatalf("wrap-up block:\n%s", block)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchStopsUnderOneWorkerEstimate(t *testing.T) {
|
||||
// No worker finished yet: the default estimate applies.
|
||||
l := NewBudgetLedger(NewBudget(200_000, 0), NewBudget(0, 0))
|
||||
spend(l, 200_000-DefaultWorkerEstimateTokens)
|
||||
if st := l.State(); !st.Dispatch {
|
||||
t.Fatalf("remaining == default estimate: %+v, want dispatch", st)
|
||||
}
|
||||
if spend(l, 1); l.State().Dispatch || l.State().AtMargin {
|
||||
t.Fatalf("below the default estimate, above the reserve: %+v, want no dispatch", l.State())
|
||||
}
|
||||
// Finished workers: their average spend is the estimate (90k, 30k → 60k).
|
||||
l = NewBudgetLedger(NewBudget(300_000, 0), NewBudget(0, 0))
|
||||
finishWorker(l, 90_000)
|
||||
finishWorker(l, 30_000)
|
||||
spend(l, 120_000) // remaining 60k
|
||||
if st := l.State(); st.Estimate.Tokens != 60_000 || !st.Dispatch {
|
||||
t.Fatalf("average estimate: %+v, want 60000 and dispatch", st)
|
||||
}
|
||||
if spend(l, 1); l.State().Dispatch || l.State().AtMargin {
|
||||
t.Fatalf("below the 60000 estimate, above the reserve: %+v, want no dispatch", l.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBudgetUnsetIsPassthrough(t *testing.T) {
|
||||
for _, v := range []string{"", "0", "abc"} {
|
||||
for _, k := range []string{"ARTEX_TASK_BUDGET_TOKENS", "ARTEX_TASK_BUDGET_TURNS", "ARTEX_INTENT_BUDGET_TOKENS", "ARTEX_INTENT_BUDGET_TURNS"} {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
if NewBudgetLedger(BudgetsFromEnv()) != nil {
|
||||
t.Fatalf("env %q: got a ledger, want nil (no budget)", v)
|
||||
}
|
||||
}
|
||||
// Without a budget on ctx the planner input and the worker turns are unchanged.
|
||||
ctx := context.Background()
|
||||
situational := plannerSituational(nil, triggerLookup{}, nil, newDataEnvelope())
|
||||
if plannerRoundInput("l", situational, "", "g", "") != plannerRoundInput("l", situational+plannerBudgetBlock(ctx), "", "g", "") {
|
||||
t.Fatal("planner input changed without a budget")
|
||||
}
|
||||
if got := (&Worker{maxTurns: 40}).turnCap(ctx); got != 40 {
|
||||
t.Fatalf("worker turns without budget = %d, want 40", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBudgetCountsMatchUsageFixture(t *testing.T) {
|
||||
// One entry per llm_usage row; InputTokens already includes the cache buckets.
|
||||
planner := []llm.Usage{
|
||||
{InputTokens: 12_000, OutputTokens: 800, CacheReadTokens: 9_000},
|
||||
{InputTokens: 15_500, OutputTokens: 1_200, CacheReadTokens: 12_000, CacheWriteTokens: 3_000},
|
||||
}
|
||||
worker := []llm.Usage{{InputTokens: 4_000, OutputTokens: 300}, {InputTokens: 6_100, CacheWriteTokens: 6_000}, {}} // {} = an errored call's row
|
||||
l := NewBudgetLedger(NewBudget(1_000_000, 1_000), NewBudget(0, 0))
|
||||
for _, u := range planner {
|
||||
l.Record(u)
|
||||
}
|
||||
m := l.StartIntent()
|
||||
for _, u := range worker {
|
||||
m.Record(u)
|
||||
}
|
||||
m.Finish()
|
||||
const want = 12_000 + 800 + 15_500 + 1_200 + 4_000 + 300 + 6_100
|
||||
if st := l.State(); st.Spent != (Spend{want, 5}) || st.Remaining != (Budget{1_000_000 - want, 995}) || st.Estimate != (Spend{10_400, 3}) {
|
||||
t.Fatalf("spent %+v remaining %+v estimate %+v, want {%d 5}, remaining, {10400 3}", st.Spent, st.Remaining, st.Estimate, want)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -163,7 +163,7 @@ func (c *ChatAgent) Chat(ctx context.Context, agentKey, sessionID, message strin
|
||||
NonStreaming: c.nonStreaming(), // when this profile selects non-streaming, use Provider.Complete
|
||||
MaxTokens: c.maxTokens(), // 0 = send no cap, the server's default decides
|
||||
|
||||
// Opt-in via ARTEX_UNTRUSTED_BOUNDARY (default off): nonce-frame tool output as untrusted data.
|
||||
// On by default; ARTEX_UNTRUSTED_BOUNDARY=0 opts out: nonce-frame tool output as untrusted data.
|
||||
UntrustedOutputBoundary: untrustedBoundaryEnabled(),
|
||||
}
|
||||
if c.guard != nil {
|
||||
|
||||
+1
-1
@@ -163,7 +163,7 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, mainSeg int, as *db.
|
||||
NonStreaming: m.nonStreaming(), // when this profile selects non-streaming, use Provider.Complete
|
||||
MaxTokens: m.maxTokens(), // 0 = send no cap, the server's default decides
|
||||
|
||||
// Opt-in via ARTEX_UNTRUSTED_BOUNDARY (default off): nonce-frame tool output as untrusted data.
|
||||
// On by default; ARTEX_UNTRUSTED_BOUNDARY=0 opts out: nonce-frame tool output as untrusted data.
|
||||
UntrustedOutputBoundary: untrustedBoundaryEnabled(),
|
||||
}
|
||||
if hooks != nil { // typed-nil guard: only set when concrete (avoids harness panic)
|
||||
|
||||
+17
-2
@@ -299,6 +299,18 @@ func factIDsYielded(ts *db.ExplorationStore, id int64) string {
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// plannerToolQuery is the Tool RAG query for one planning round: the goal plus the
|
||||
// short texts the round's triggers already carry (no store lookups).
|
||||
func plannerToolQuery(goal string, triggers []TriggerEvent) string {
|
||||
parts := []string{goal}
|
||||
for _, ev := range triggers {
|
||||
parts = append(parts, ev.Summary, ev.NewGoal)
|
||||
parts = append(parts, ev.Goals...)
|
||||
parts = append(parts, ev.Hints...)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
// intentSummary reads an intent node's one-line summary (best-effort, "?" on miss).
|
||||
func intentSummary(ts *db.ExplorationStore, id int64) string {
|
||||
n, err := ts.GetNode(id)
|
||||
@@ -470,7 +482,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
|
||||
// When the asset-coverage feature is off, drop add_task_scope/list_untested_assets (not in the prompt).
|
||||
base := append(tsx.DropCoverageTools(tsx.PlannerTools()), actool.DefaultTools()...)
|
||||
ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts)})
|
||||
tools, def, cleanup := AugmentTools(ctx, "planner", base)
|
||||
tools, def, cleanup := AugmentTools(WithToolQuery(ctx, plannerToolQuery(goal, triggers)), "planner", base)
|
||||
defer cleanup()
|
||||
// The key situation (just-finished intents + the pre-fetched full graph) goes into
|
||||
// [this round's user input] (see input below); the system prompt keeps only the
|
||||
@@ -488,6 +500,9 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
|
||||
if tc.Final {
|
||||
situational += "\n\n[Task endgame wrap-up (special instruction for this round, overriding the normal planning flow above)]: " + resolveTaskTimeoutWrapup("planner")
|
||||
}
|
||||
// Task budget (off by default, see budget.go): the remaining-budget line, and on
|
||||
// the forced wrap-up round the budget wrap-up instruction. Empty without a budget.
|
||||
situational += plannerBudgetBlock(ctx)
|
||||
// This task's work dir <workDir>/tasks/<taskID>; create it first.
|
||||
taskDir := ensureRunDir(p.workDir, taskID, 0)
|
||||
ctx = intercept.WithReviewContext(ctx, taskDir, intercept.ReviewBackground{})
|
||||
@@ -541,7 +556,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
|
||||
NonStreaming: p.nonStreaming(), // when this profile selects non-streaming, use Provider.Complete
|
||||
MaxTokens: p.maxTokens(), // 0 = send no cap; the server default decides
|
||||
|
||||
// Opt-in via ARTEX_UNTRUSTED_BOUNDARY (default off): nonce-frame tool output as untrusted data.
|
||||
// On by default; ARTEX_UNTRUSTED_BOUNDARY=0 opts out: nonce-frame tool output as untrusted data.
|
||||
UntrustedOutputBoundary: untrustedBoundaryEnabled(),
|
||||
}
|
||||
if hooks != nil { // typed-nil guard: only set when concrete (avoids harness panic)
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
// Tool RAG: a lexical PROJECTION of an agent's already-bound tool list. It runs in
|
||||
// AugmentTools after ToolResolve (binding + enabled filters), so it only ever
|
||||
// removes tools from that list: it cannot add an unbound or disabled tool, never
|
||||
// rewrites a description or schema, and never reads the database. Off by default.
|
||||
|
||||
const (
|
||||
toolRAGEnv = "ARTEX_TOOL_RAG" // "1"/"true"/"on"/"yes" enables; anything else (incl. unset) = off
|
||||
toolRAGTopKEnv = "ARTEX_TOOL_RAG_TOPK" // non-core tools kept; default toolRAGDefaultK
|
||||
toolRAGDefaultK = 12
|
||||
toolRAGMinCatalog = 20 // smaller catalogs are passed through whole
|
||||
)
|
||||
|
||||
// toolRAGCore is always kept when bound and never counts against k.
|
||||
var toolRAGCore = map[string]bool{
|
||||
"record_fact": true, "insert_assets": true, "report_finding": true, "list_findings": true,
|
||||
"graph_overview": true, "node_detail": true, "add_intent": true, "steer_work": true,
|
||||
"ask_operator": true, "prove_goal": true, "goal_met": true, "list_assets": true,
|
||||
}
|
||||
|
||||
type toolQueryKey struct{}
|
||||
|
||||
// WithToolQuery attaches the retrieval query (goal + current intent/round summary)
|
||||
// read by AugmentTools. No query = no projection.
|
||||
func WithToolQuery(ctx context.Context, q string) context.Context {
|
||||
return context.WithValue(ctx, toolQueryKey{}, q)
|
||||
}
|
||||
|
||||
// ToolScorer returns one relevance score per tool (same order as tools); higher is
|
||||
// more relevant. It must be pure: no network, no database.
|
||||
type ToolScorer func(query string, tools []actool.CoreTool) ([]float64, error)
|
||||
|
||||
// projectTools keeps the tools in keep (core / harness / deferred names) plus the
|
||||
// k best-scoring other tools, preserving the input order. It falls back to the full
|
||||
// list (projected=false) on an empty query, a catalog under toolRAGMinCatalog, or a
|
||||
// scorer error.
|
||||
func projectTools(query string, tools []actool.CoreTool, keep map[string]bool, k int, score ToolScorer) (out []actool.CoreTool, projected bool, err error) {
|
||||
if strings.TrimSpace(query) == "" || len(tools) < toolRAGMinCatalog {
|
||||
return tools, false, nil
|
||||
}
|
||||
var cands []actool.CoreTool
|
||||
for _, t := range tools {
|
||||
if !keep[t.Name()] {
|
||||
cands = append(cands, t)
|
||||
}
|
||||
}
|
||||
ranked, err := rankTools(query, cands, score)
|
||||
if err != nil {
|
||||
return tools, false, err
|
||||
}
|
||||
sel := map[string]bool{}
|
||||
for i := 0; i < len(ranked) && i < k; i++ {
|
||||
sel[ranked[i].Name()] = true
|
||||
}
|
||||
for _, t := range tools {
|
||||
if keep[t.Name()] || sel[t.Name()] {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out, true, nil
|
||||
}
|
||||
|
||||
// rankTools orders tools by score descending, ties broken by name ascending.
|
||||
func rankTools(query string, tools []actool.CoreTool, score ToolScorer) ([]actool.CoreTool, error) {
|
||||
scores, err := score(query, tools)
|
||||
if err == nil && len(scores) != len(tools) {
|
||||
err = fmt.Errorf("scorer returned %d scores for %d tools", len(scores), len(tools))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idx := make([]int, len(tools))
|
||||
for i := range idx {
|
||||
idx[i] = i
|
||||
}
|
||||
sort.SliceStable(idx, func(a, b int) bool {
|
||||
if sa, sb := scores[idx[a]], scores[idx[b]]; sa != sb {
|
||||
return sa > sb
|
||||
}
|
||||
return tools[idx[a]].Name() < tools[idx[b]].Name()
|
||||
})
|
||||
out := make([]actool.CoreTool, len(idx))
|
||||
for i, j := range idx {
|
||||
out[i] = tools[j]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// bm25Score is the default ToolScorer: Okapi BM25 (k1=1.2, b=0.75) over each tool's
|
||||
// name + description + parameter names, IDF computed across the candidate set.
|
||||
func bm25Score(query string, tools []actool.CoreTool) ([]float64, error) {
|
||||
const k1, b = 1.2, 0.75
|
||||
docs := make([]map[string]int, len(tools))
|
||||
lens := make([]float64, len(tools))
|
||||
df := map[string]int{}
|
||||
var total float64
|
||||
for i, t := range tools {
|
||||
text := t.Name() + " " + t.Description()
|
||||
if props, ok := t.InputSchema()["properties"].(map[string]any); ok {
|
||||
for p := range props {
|
||||
text += " " + p
|
||||
}
|
||||
}
|
||||
tf := map[string]int{}
|
||||
for _, w := range tokenize(text) {
|
||||
tf[w]++
|
||||
lens[i]++
|
||||
}
|
||||
for w := range tf {
|
||||
df[w]++
|
||||
}
|
||||
docs[i], total = tf, total+lens[i]
|
||||
}
|
||||
scores := make([]float64, len(tools))
|
||||
n := float64(len(tools))
|
||||
avg, seen := math.Max(total/math.Max(n, 1), 1), map[string]bool{}
|
||||
for _, q := range tokenize(query) {
|
||||
if seen[q] || df[q] == 0 {
|
||||
continue
|
||||
}
|
||||
seen[q] = true
|
||||
idf := math.Log(1 + (n-float64(df[q])+0.5)/(float64(df[q])+0.5))
|
||||
for i, tf := range docs {
|
||||
if f := float64(tf[q]); f > 0 {
|
||||
scores[i] += idf * f * (k1 + 1) / (f + k1*(1-b+b*lens[i]/avg))
|
||||
}
|
||||
}
|
||||
}
|
||||
return scores, nil
|
||||
}
|
||||
|
||||
// tokenize lowercases and splits on non-alphanumerics ("sql_injection" → sql, injection).
|
||||
func tokenize(s string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
}
|
||||
|
||||
// toolRAGKeep: names never hidden by the projection — the core set, the harness
|
||||
// tools every agent relies on (file/shell tools, interactive shell, Skill
|
||||
// meta-tool), and deferred tools (schemas already withheld; dropping them would
|
||||
// desync the deferred block and the skill unlock gate).
|
||||
func toolRAGKeep(deferred []string) map[string]bool {
|
||||
keep := map[string]bool{"Skill": true}
|
||||
for n := range toolRAGCore {
|
||||
keep[n] = true
|
||||
}
|
||||
for _, t := range append(actool.DefaultTools(), actool.ShellSessionTools()...) {
|
||||
keep[t.Name()] = true
|
||||
}
|
||||
for _, n := range deferred {
|
||||
keep[n] = true
|
||||
}
|
||||
return keep
|
||||
}
|
||||
|
||||
// applyToolRAG is the AugmentTools hook. Off unless ARTEX_TOOL_RAG is enabled.
|
||||
func applyToolRAG(ctx context.Context, agentKey string, tools []actool.CoreTool, deferred []string, score ToolScorer) []actool.CoreTool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(toolRAGEnv))) {
|
||||
case "1", "true", "on", "yes":
|
||||
default:
|
||||
return tools
|
||||
}
|
||||
k, kerr := strconv.Atoi(strings.TrimSpace(os.Getenv(toolRAGTopKEnv)))
|
||||
if kerr != nil || k <= 0 {
|
||||
k = toolRAGDefaultK
|
||||
}
|
||||
query, _ := ctx.Value(toolQueryKey{}).(string)
|
||||
out, projected, err := projectTools(query, tools, toolRAGKeep(deferred), k, score)
|
||||
if err != nil {
|
||||
log.Printf("[tool-rag] %s: scoring failed, using full catalog (%d tools): %v", agentKey, len(tools), err)
|
||||
} else if !projected {
|
||||
log.Printf("[tool-rag] %s: full catalog (%d tools; empty query or under %d)", agentKey, len(tools), toolRAGMinCatalog)
|
||||
} else {
|
||||
names := make([]string, len(out))
|
||||
for i, t := range out {
|
||||
names[i] = t.Name()
|
||||
}
|
||||
log.Printf("[tool-rag] %s: selected %d/%d tools: %s", agentKey, len(out), len(tools), strings.Join(names, ", "))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
func ragTool(name, desc string, params ...string) actool.CoreTool {
|
||||
props := map[string]any{}
|
||||
for _, p := range params {
|
||||
props[p] = map[string]any{"type": "string"}
|
||||
}
|
||||
return actool.Build(actool.Spec{Name: name, Description: desc, Schema: map[string]any{"type": "object", "properties": props}})
|
||||
}
|
||||
|
||||
// ragCatalog: two core tools, three meaningful fixtures, and filler up to n tools.
|
||||
func ragCatalog(n int) []actool.CoreTool {
|
||||
tools := []actool.CoreTool{
|
||||
ragTool("record_fact", "Record a fact"),
|
||||
ragTool("port_scan", "Scan TCP ports on a host, nmap-like service discovery", "host", "ports"),
|
||||
ragTool("sqli_probe", "Probe web parameters for SQL injection", "url", "param"),
|
||||
ragTool("http_request", "Send an HTTP request to a web endpoint", "url", "method"),
|
||||
ragTool("list_assets", "List assets"),
|
||||
}
|
||||
for i := len(tools); i < n; i++ {
|
||||
tools = append(tools, ragTool(fmt.Sprintf("filler_%02d", i), "Unrelated helper"))
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func ragNames(tools []actool.CoreTool) (out []string) {
|
||||
for _, t := range tools {
|
||||
out = append(out, t.Name())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const ragQuery = "Find SQL injection in the web login form"
|
||||
|
||||
func TestToolRAGRanksInjectionAboveScan(t *testing.T) {
|
||||
ranked, err := rankTools(ragQuery, ragCatalog(5), bm25Score)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := ragNames(ranked)
|
||||
if scan := slices.Index(n, "port_scan"); slices.Index(n, "sqli_probe") > scan || slices.Index(n, "http_request") > scan {
|
||||
t.Fatalf("ranking = %v, want sqli_probe and http_request above port_scan", n)
|
||||
}
|
||||
if n[0] != "sqli_probe" {
|
||||
t.Fatalf("top = %s, want sqli_probe", n[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRAGCoreKeptAndKRespected(t *testing.T) {
|
||||
keep := toolRAGKeep(nil)
|
||||
for _, k := range []int{1, 3} {
|
||||
out, projected, err := projectTools(ragQuery, ragCatalog(25), keep, k, bm25Score)
|
||||
if err != nil || !projected {
|
||||
t.Fatalf("k=%d: projected=%v err=%v", k, projected, err)
|
||||
}
|
||||
n := ragNames(out)
|
||||
if !slices.Contains(n, "record_fact") || !slices.Contains(n, "list_assets") {
|
||||
t.Fatalf("k=%d: core tool hidden: %v", k, n)
|
||||
}
|
||||
if slices.Contains(n, "goal_met") {
|
||||
t.Fatalf("k=%d: unbound core tool added: %v", k, n)
|
||||
}
|
||||
if nonCore := len(out) - 2; nonCore > k {
|
||||
t.Fatalf("k=%d: %d non-core tools selected: %v", k, nonCore, n)
|
||||
}
|
||||
if !slices.Contains(n, "sqli_probe") {
|
||||
t.Fatalf("k=%d: best match missing: %v", k, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The projection runs after ToolResolve, so a tool the binding/enabled filter
|
||||
// dropped is never selected even when it is the best lexical match.
|
||||
func TestToolRAGNeverSelectsUnboundOrDisabled(t *testing.T) {
|
||||
oldResolve, oldAugment, oldPolicy := ToolResolve, ToolAugment, FindingTrafficBindingEnabled
|
||||
t.Cleanup(func() { ToolResolve, ToolAugment, FindingTrafficBindingEnabled = oldResolve, oldAugment, oldPolicy })
|
||||
ToolAugment, FindingTrafficBindingEnabled = nil, nil
|
||||
t.Setenv(toolRAGEnv, "on")
|
||||
t.Setenv(toolRAGTopKEnv, "2")
|
||||
ToolResolve = func(_ context.Context, _ string, in []actool.CoreTool) []actool.CoreTool {
|
||||
var out []actool.CoreTool
|
||||
for _, tool := range in {
|
||||
if tool.Name() != "sql_injection_exploit" {
|
||||
out = append(out, tool)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
base := append(ragCatalog(25), ragTool("sql_injection_exploit", "Exploit SQL injection in web login form", "url"))
|
||||
out, _, cleanup := AugmentTools(WithToolQuery(t.Context(), ragQuery), "worker", base)
|
||||
defer cleanup()
|
||||
n := ragNames(out)
|
||||
if slices.Contains(n, "sql_injection_exploit") {
|
||||
t.Fatalf("unbound tool selected: %v", n)
|
||||
}
|
||||
if len(out) != 4 || !slices.Contains(n, "sqli_probe") {
|
||||
t.Fatalf("got %v, want 2 core + top 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRAGFallbacks(t *testing.T) {
|
||||
keep := toolRAGKeep(nil)
|
||||
failing := func(string, []actool.CoreTool) ([]float64, error) { return nil, errors.New("boom") }
|
||||
for name, c := range map[string]struct {
|
||||
query string
|
||||
n int
|
||||
score ToolScorer
|
||||
}{"empty query": {" ", 25, bm25Score}, "catalog under 20": {ragQuery, 19, bm25Score}, "scoring error": {ragQuery, 25, failing}} {
|
||||
in := ragCatalog(c.n)
|
||||
out, projected, err := projectTools(c.query, in, keep, 1, c.score)
|
||||
if projected || (err != nil) != (name == "scoring error") || !slices.Equal(ragNames(out), ragNames(in)) {
|
||||
t.Errorf("%s: projected=%v err=%v out=%v", name, projected, err, ragNames(out))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRAGDeterministic(t *testing.T) {
|
||||
keep := toolRAGKeep(nil)
|
||||
// "zzz" matches nothing: every candidate ties at 0 and the name tie-break decides.
|
||||
for _, q := range []string{ragQuery, "zzz"} {
|
||||
first, _, _ := projectTools(q, ragCatalog(30), keep, 4, bm25Score)
|
||||
for i := 0; i < 20; i++ {
|
||||
again, _, _ := projectTools(q, ragCatalog(30), keep, 4, bm25Score)
|
||||
if !slices.Equal(ragNames(first), ragNames(again)) {
|
||||
t.Fatalf("%q: run %d = %v, want %v", q, i, ragNames(again), ragNames(first))
|
||||
}
|
||||
}
|
||||
}
|
||||
ranked, _ := rankTools("zzz", ragCatalog(8), bm25Score)
|
||||
if n := ragNames(ranked); !slices.IsSorted(n) {
|
||||
t.Fatalf("ties not ordered by name: %v", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Pins the keep set beyond the core list: harness tools, Skill and deferred names
|
||||
// are never hidden, even when the query matches something else.
|
||||
func TestToolRAGKeepsHarnessSkillAndDeferred(t *testing.T) {
|
||||
in := append(ragCatalog(15), actool.DefaultTools()...)
|
||||
in = append(in, ragTool("Skill", "Load a skill"), ragTool("mcp__srv__crawl", "Crawl pages"))
|
||||
out, projected, err := projectTools(ragQuery, in, toolRAGKeep([]string{"mcp__srv__crawl"}), 1, bm25Score)
|
||||
if err != nil || !projected || len(in) <= 20 {
|
||||
t.Fatalf("projected=%v err=%v n=%d", projected, err, len(in))
|
||||
}
|
||||
n := ragNames(out)
|
||||
for _, want := range append(ragNames(actool.DefaultTools()), "Skill", "mcp__srv__crawl", "sqli_probe") {
|
||||
if !slices.Contains(n, want) {
|
||||
t.Fatalf("%s hidden: %v", want, n)
|
||||
}
|
||||
}
|
||||
if slices.Contains(n, "http_request") {
|
||||
t.Fatalf("k=1 exceeded: %v", n)
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,15 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// untrustedBoundaryEnabled reports whether ARTEX_UNTRUSTED_BOUNDARY opts in to
|
||||
// Norma's untrusted-output boundary (agentcore.Options.UntrustedOutputBoundary:
|
||||
// tool results framed between nonce-tagged markers). Only "1", "true" and "on"
|
||||
// (case- and space-insensitive) enable it; unset, empty or anything else keeps
|
||||
// it off.
|
||||
// untrustedBoundaryEnabled reports whether Norma's untrusted-output boundary
|
||||
// (agentcore.Options.UntrustedOutputBoundary: tool results framed between
|
||||
// nonce-tagged markers) is on. It is on by default: unset, empty or any other
|
||||
// value keeps it on; only ARTEX_UNTRUSTED_BOUNDARY set to "0", "false", "off"
|
||||
// or "no" (case- and space-insensitive) turns it off.
|
||||
func untrustedBoundaryEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("ARTEX_UNTRUSTED_BOUNDARY"))) {
|
||||
case "1", "true", "on":
|
||||
return true
|
||||
case "0", "false", "off", "no":
|
||||
return false
|
||||
}
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -11,15 +11,20 @@ func TestUntrustedBoundaryEnabled(t *testing.T) {
|
||||
env string
|
||||
want bool
|
||||
}{
|
||||
{"", false},
|
||||
{"", true},
|
||||
{"0", false},
|
||||
{"false", false},
|
||||
{"yes", false},
|
||||
{"off", false},
|
||||
{"no", false},
|
||||
{" FALSE ", false},
|
||||
{"Off", false},
|
||||
{"NO", false},
|
||||
{"1", true},
|
||||
{"true", true},
|
||||
{"on", true},
|
||||
{"yes", true},
|
||||
{" TRUE ", true},
|
||||
{"On", true},
|
||||
{"anything", true},
|
||||
} {
|
||||
t.Setenv("ARTEX_UNTRUSTED_BOUNDARY", tc.env)
|
||||
if got := untrustedBoundaryEnabled(); got != tc.want {
|
||||
@@ -28,11 +33,11 @@ func TestUntrustedBoundaryEnabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUntrustedBoundaryDisabledWhenUnset(t *testing.T) {
|
||||
t.Setenv("ARTEX_UNTRUSTED_BOUNDARY", "1") // registers restoration of the original value
|
||||
func TestUntrustedBoundaryEnabledWhenUnset(t *testing.T) {
|
||||
t.Setenv("ARTEX_UNTRUSTED_BOUNDARY", "0") // registers restoration of the original value
|
||||
os.Unsetenv("ARTEX_UNTRUSTED_BOUNDARY")
|
||||
if untrustedBoundaryEnabled() {
|
||||
t.Fatal("ARTEX_UNTRUSTED_BOUNDARY unset: got true, want false")
|
||||
if !untrustedBoundaryEnabled() {
|
||||
t.Fatal("ARTEX_UNTRUSTED_BOUNDARY unset: got false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+37
-2
@@ -2,7 +2,10 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
|
||||
"github.com/Autumn-27/artex/egress"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
@@ -17,11 +20,43 @@ import (
|
||||
// on the direct retry is turned off for the run, since it would leave without
|
||||
// the proxy. The tool is added after the session's own tools, where agentcore
|
||||
// adds it, so the tool list keeps its order. caCert is the recording proxy's
|
||||
// CA, trusted to verify the HTTPS it re-signs. tools is not modified.
|
||||
// CA, trusted to verify the HTTPS it re-signs. Each call goes through the shared
|
||||
// egress governor (see governedFetch). tools is not modified.
|
||||
func withWebFetch(ctx context.Context, tools []actool.CoreTool, proxy, caCert string) []actool.CoreTool {
|
||||
scope, scoped := intercept.TargetScopeFromContext(ctx)
|
||||
gated := scoped && scope.Enabled != nil && scope.Enabled()
|
||||
webFetch := actool.NewWebFetch(actool.WebFetchConfig{Proxy: proxy, CACert: caCert, CheckRedirect: intercept.RedirectCheck("WebFetch"),
|
||||
DialContext: intercept.PinnedDialer("WebFetch", scoped), DisableDirectRetry: gated})
|
||||
return append(tools[:len(tools):len(tools)], webFetch)
|
||||
return append(tools[:len(tools):len(tools)], governedFetch{webFetch, egressGovernor()})
|
||||
}
|
||||
|
||||
// egressGovernor is the governor of WebFetch; a test may pin it.
|
||||
var egressGovernor = egress.Shared
|
||||
|
||||
// governedFetch runs each WebFetch call as one send under the egress governor
|
||||
// (off by default; then the call runs unchanged). Under a noise profile it
|
||||
// paces the call by the rate, jitter and concurrency of the URL's host, and
|
||||
// nothing else: WebFetch calls are neither coalesced nor cached, since each
|
||||
// tool carries its own proxy and redirect and dial gate (see egress.Do).
|
||||
// Norma owns the HTTP client, so redirect hops are not paced separately and a
|
||||
// 429/503 is not retried.
|
||||
type governedFetch struct {
|
||||
actool.CoreTool
|
||||
g *egress.Governor
|
||||
}
|
||||
|
||||
func (f governedFetch) Call(ctx context.Context, input json.RawMessage, tc *actool.ToolContext) (actool.Result, error) {
|
||||
var in struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if json.Unmarshal(input, &in) != nil {
|
||||
return f.CoreTool.Call(ctx, input, tc)
|
||||
}
|
||||
u, err := url.Parse(in.URL)
|
||||
if err != nil || u.Host == "" {
|
||||
return f.CoreTool.Call(ctx, input, tc) // the tool reports the bad URL
|
||||
}
|
||||
return egress.Do(f.g, ctx, u, func() (actool.Result, error) {
|
||||
return f.CoreTool.Call(ctx, input, tc)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/egress"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
// pinNoiseOff runs t with the off noise profile, whatever ARTEX_NOISE_PROFILE says.
|
||||
func pinNoiseOff(t *testing.T) {
|
||||
old := egressGovernor
|
||||
egressGovernor = func() *egress.Governor { return egress.New(egress.Profile{Name: "off"}) }
|
||||
t.Cleanup(func() { egressGovernor = old })
|
||||
}
|
||||
|
||||
// Under a profile, two WebFetch calls on the same URL in one task both run the
|
||||
// tool and its redirect gate: WebFetch results are never cached.
|
||||
func TestWebFetchProfileDoesNotCache(t *testing.T) {
|
||||
pair := newRedirectPair(t)
|
||||
var checks atomic.Int64
|
||||
inner := actool.NewWebFetch(actool.WebFetchConfig{CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
checks.Add(1)
|
||||
return nil
|
||||
}})
|
||||
tool := governedFetch{inner, egress.New(egress.Profiles["balanced"])}
|
||||
ctx := intercept.WithTargetScope(t.Context(), intercept.TargetScope{TaskID: "7"})
|
||||
for range 2 {
|
||||
res, err := tool.Call(ctx, json.RawMessage(fetchInputJSON(pair.start)), &actool.ToolContext{})
|
||||
if err != nil || res.IsError {
|
||||
t.Fatalf("WebFetch: err=%v result=%q", err, res.Flatten())
|
||||
}
|
||||
}
|
||||
if checks.Load() != 2 || pair.startHits.Load() != 2 || pair.finalHits.Load() != 2 {
|
||||
t.Fatalf("redirect checks %d, start hits %d, final hits %d; want 2 each (no cache hit)",
|
||||
checks.Load(), pair.startHits.Load(), pair.finalHits.Load())
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,7 @@ func TestWebFetchGatedScopeHasNoDirectRetry(t *testing.T) {
|
||||
// with agentcore's own tool: to the end of the chain, and the same error after
|
||||
// 10 hops. No database.
|
||||
func TestWebFetchRedirectGateOffUnchanged(t *testing.T) {
|
||||
pinNoiseOff(t) // a gate-off regression test: independent of ARTEX_NOISE_PROFILE
|
||||
loop := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, r.URL.Path+"x", http.StatusFound)
|
||||
}))
|
||||
|
||||
+3
-3
@@ -415,7 +415,7 @@ func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db.
|
||||
// are kept as usual.
|
||||
base = append(base, defaultToolsExcept("MultiEdit", "Glob", "Grep")...)
|
||||
ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts), IntentID: intent.ID})
|
||||
tools, def, cleanup := AugmentTools(ctx, "worker", base)
|
||||
tools, def, cleanup := AugmentTools(WithToolQuery(ctx, nodeSummary(intent)), "worker", base)
|
||||
defer cleanup()
|
||||
|
||||
// The intent is the worker's [sole duty, an invariant across the whole run] → it
|
||||
@@ -503,7 +503,7 @@ func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db.
|
||||
// default (tools need no -x/-k).
|
||||
BashEnv: proxyEnv(w.proxyAddr, w.proxyCACert),
|
||||
WorkingDir: runDir,
|
||||
MaxTurns: w.maxTurns, // 0 = unlimited (configurable in agent management)
|
||||
MaxTurns: w.turnCap(ctx), // 0 = unlimited (configurable in agent management); clamped by the intent budget
|
||||
// Wall-clock budget, judged at turn boundaries without interrupting mid-flight;
|
||||
// 0 = unlimited. When a task-level deadline exists, clamp to min(own budget, time
|
||||
// until the deadline) so this run naturally enters wrap-up when the task hits its
|
||||
@@ -524,7 +524,7 @@ func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db.
|
||||
NonStreaming: w.nonStreaming(), // when this profile selects non-streaming, use Provider.Complete
|
||||
MaxTokens: w.maxTokens(), // 0 = send no cap; the server default decides
|
||||
|
||||
// Opt-in via ARTEX_UNTRUSTED_BOUNDARY (default off): nonce-frame tool output as untrusted data.
|
||||
// On by default; ARTEX_UNTRUSTED_BOUNDARY=0 opts out: nonce-frame tool output as untrusted data.
|
||||
UntrustedOutputBoundary: untrustedBoundaryEnabled(),
|
||||
}
|
||||
if hooks != nil { // typed-nil guard: only set when concrete (avoids harness panic)
|
||||
|
||||
@@ -36,7 +36,18 @@ services:
|
||||
ARTEX_LLM_PROXY: ${ARTEX_LLM_PROXY:-}
|
||||
ARTEX_JWT_KEY_DIR: /app/keys
|
||||
ARTEX_TRUSTED_PROXIES: ${ARTEX_TRUSTED_PROXIES:-}
|
||||
# Untrusted-output boundary: on by default (empty = on); ARTEX_UNTRUSTED_BOUNDARY=0 in .env
|
||||
# disables it. Cost: about 154 bytes per tool result plus one system note.
|
||||
ARTEX_UNTRUSTED_BOUNDARY: ${ARTEX_UNTRUSTED_BOUNDARY:-}
|
||||
ARTEX_UPDATE_CHECK: ${ARTEX_UPDATE_CHECK:-}
|
||||
# Noise profile for target requests: off (default) | quiet | balanced | aggressive.
|
||||
ARTEX_NOISE_PROFILE: ${ARTEX_NOISE_PROFILE:-}
|
||||
ARTEX_TARGET_USER_AGENT: ${ARTEX_TARGET_USER_AGENT:-}
|
||||
# Hierarchical run budgets: unset or 0 = unlimited (no change). See README.
|
||||
ARTEX_TASK_BUDGET_TOKENS: ${ARTEX_TASK_BUDGET_TOKENS:-}
|
||||
ARTEX_TASK_BUDGET_TURNS: ${ARTEX_TASK_BUDGET_TURNS:-}
|
||||
ARTEX_INTENT_BUDGET_TOKENS: ${ARTEX_INTENT_BUDGET_TOKENS:-}
|
||||
ARTEX_INTENT_BUDGET_TURNS: ${ARTEX_INTENT_BUDGET_TURNS:-}
|
||||
ports:
|
||||
- "${ARTEX_BIND_ADDR:-127.0.0.1}:8787:8787"
|
||||
- "127.0.0.1:8788:8788"
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
// Package egress is the governor the platform-owned HTTP requests to a target
|
||||
// pass through (WebFetch, the http custom tools, the enrich probe). A noise
|
||||
// profile (ARTEX_NOISE_PROFILE) sets every lever: a token bucket, a jitter and a
|
||||
// concurrency cap per host, a global concurrency cap, backoff on 429/503, and,
|
||||
// for the GET requests of one task, coalescing and a short cache. The default
|
||||
// profile, off, is a strict passthrough: requests and timing are unchanged.
|
||||
package egress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
// Profile is one noise level. The zero Profile is off. Later levers (scanner
|
||||
// flags, proxy throttle) are new fields here.
|
||||
type Profile struct {
|
||||
Name string
|
||||
RPS float64 // token refill per host, requests per second; 0 = off
|
||||
Burst int // bucket size per host
|
||||
MaxPerHost int // requests in flight per host
|
||||
MaxGlobal int // requests in flight across all hosts
|
||||
Jitter float64 // each send waits a uniform [0, min(Jitter/RPS, MaxJitter)]
|
||||
CacheTTL time.Duration // per-task cache of identical GETs; 0 = coalescing only
|
||||
MaxRetries int // retries after a 429/503 (bodiless GET/HEAD/OPTIONS only)
|
||||
BaseDelay time.Duration // backoff without Retry-After, doubled per retry
|
||||
MaxDelay time.Duration // cap of one backoff; a longer Retry-After is not retried
|
||||
UserAgent string // ARTEX_TARGET_USER_AGENT, set on requests that have none; "" = unchanged
|
||||
}
|
||||
|
||||
// MaxJitter caps the jitter of one send.
|
||||
const MaxJitter = 250 * time.Millisecond
|
||||
|
||||
// Profiles are the values of ARTEX_NOISE_PROFILE besides off (the default).
|
||||
var Profiles = map[string]Profile{
|
||||
"quiet": {Name: "quiet", RPS: 2, Burst: 4, MaxPerHost: 1, MaxGlobal: 8, Jitter: 0.5, CacheTTL: time.Minute, MaxRetries: 3, BaseDelay: 2 * time.Second, MaxDelay: time.Minute},
|
||||
"balanced": {Name: "balanced", RPS: 5, Burst: 10, MaxPerHost: 2, MaxGlobal: 16, Jitter: 0.2, CacheTTL: 30 * time.Second, MaxRetries: 3, BaseDelay: time.Second, MaxDelay: 30 * time.Second},
|
||||
"aggressive": {Name: "aggressive", RPS: 20, Burst: 40, MaxPerHost: 4, MaxGlobal: 32, Jitter: 0.1, CacheTTL: 10 * time.Second, MaxRetries: 2, BaseDelay: 500 * time.Millisecond, MaxDelay: 10 * time.Second},
|
||||
}
|
||||
|
||||
// ProfileFromEnv returns the profile ARTEX_NOISE_PROFILE names; unset, "off" or
|
||||
// unknown is off.
|
||||
func ProfileFromEnv() Profile {
|
||||
name := strings.ToLower(strings.TrimSpace(os.Getenv("ARTEX_NOISE_PROFILE")))
|
||||
p, ok := Profiles[name]
|
||||
if !ok {
|
||||
if name != "" && name != "off" {
|
||||
log.Printf("[egress] ARTEX_NOISE_PROFILE=%q is not off, quiet, balanced or aggressive; using off", name)
|
||||
}
|
||||
return Profile{Name: "off"}
|
||||
}
|
||||
if ua := strings.TrimSpace(os.Getenv("ARTEX_TARGET_USER_AGENT")); strings.ContainsFunc(ua, unicode.IsControl) {
|
||||
log.Printf("[egress] ARTEX_TARGET_USER_AGENT contains a control character; ignored")
|
||||
} else {
|
||||
p.UserAgent = ua
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Shared is the one governor of the process, built from the environment on
|
||||
// first use; WebFetch, the http custom tools and the enrich probe all use it.
|
||||
// Under a profile it logs the noise counter of each host once a minute.
|
||||
var Shared = sync.OnceValue(func() *Governor {
|
||||
g := New(ProfileFromEnv())
|
||||
log.Printf("[egress] noise profile: %s", g.p.Name)
|
||||
if !g.off() {
|
||||
go func() {
|
||||
for range time.Tick(time.Minute) {
|
||||
for _, l := range g.report() {
|
||||
log.Print(l)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
return g
|
||||
})
|
||||
|
||||
// Governor holds the per-host state and the per-task response cache.
|
||||
type Governor struct {
|
||||
p Profile
|
||||
global chan struct{}
|
||||
mu sync.Mutex
|
||||
hosts map[string]*hostState
|
||||
flights map[string]*flight
|
||||
}
|
||||
|
||||
type hostState struct {
|
||||
slots chan struct{}
|
||||
tokens float64
|
||||
last time.Time
|
||||
notBefore time.Time // end of a 429/503 backoff
|
||||
// noise counter of the current minute, and the peak per minute so far
|
||||
sent, dupes, inflight, peakInflight, peakRPM int
|
||||
}
|
||||
|
||||
type flight struct {
|
||||
done chan struct{}
|
||||
val any
|
||||
err error
|
||||
exp time.Time // zero until cached
|
||||
}
|
||||
|
||||
// New builds a Governor for p.
|
||||
func New(p Profile) *Governor {
|
||||
return &Governor{p: p, global: make(chan struct{}, max(p.MaxGlobal, 1)), hosts: map[string]*hostState{}, flights: map[string]*flight{}}
|
||||
}
|
||||
|
||||
func (g *Governor) off() bool { return g.p.RPS <= 0 }
|
||||
|
||||
// HostKey is the lowercased host, with the port when it is not the default.
|
||||
func HostKey(u *url.URL) string {
|
||||
h, p := strings.ToLower(u.Hostname()), u.Port()
|
||||
if p == "" || (u.Scheme == "http" && p == "80") || (u.Scheme == "https" && p == "443") {
|
||||
return h
|
||||
}
|
||||
return net.JoinHostPort(h, p)
|
||||
}
|
||||
|
||||
func (g *Governor) jitterBound() time.Duration {
|
||||
return min(time.Duration(g.p.Jitter/g.p.RPS*float64(time.Second)), MaxJitter)
|
||||
}
|
||||
|
||||
func (g *Governor) jitter() time.Duration { return rand.N(g.jitterBound() + 1) }
|
||||
|
||||
func (g *Governor) host(key string) *hostState {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
h := g.hosts[key]
|
||||
if h == nil {
|
||||
h = &hostState{slots: make(chan struct{}, max(g.p.MaxPerHost, 1)), tokens: float64(g.p.Burst), last: time.Now()}
|
||||
g.hosts[key] = h
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// acquire waits for a host slot, a token of the host's bucket (or the end of
|
||||
// its backoff) plus the jitter, then a global slot, taken last so a waiting
|
||||
// host holds no global slot. release frees both; it may be called twice.
|
||||
func (g *Governor) acquire(ctx context.Context, key string) (release func(), err error) {
|
||||
h := g.host(key)
|
||||
select {
|
||||
case h.slots <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if err = sleep(ctx, g.reserve(h)+g.jitter()); err == nil {
|
||||
select {
|
||||
case g.global <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
err = ctx.Err()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
<-h.slots
|
||||
return nil, err
|
||||
}
|
||||
g.mu.Lock()
|
||||
h.sent++
|
||||
h.inflight++
|
||||
h.peakInflight = max(h.peakInflight, h.inflight)
|
||||
g.mu.Unlock()
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() { g.mu.Lock(); h.inflight--; g.mu.Unlock(); <-g.global; <-h.slots })
|
||||
}, nil
|
||||
}
|
||||
|
||||
// reserve takes a token from h and returns how long to wait for it.
|
||||
func (g *Governor) reserve(h *hostState) time.Duration {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
now := time.Now()
|
||||
h.tokens = min(float64(g.p.Burst), h.tokens+now.Sub(h.last).Seconds()*g.p.RPS) - 1
|
||||
h.last = now
|
||||
return max(time.Duration(-h.tokens/g.p.RPS*float64(time.Second)), h.notBefore.Sub(now), 0)
|
||||
}
|
||||
|
||||
// backoff is the wait after a 429/503: Retry-After (seconds or HTTP date), else
|
||||
// BaseDelay doubled per retry, capped at MaxDelay. ok is false when Retry-After
|
||||
// asks for more than MaxDelay.
|
||||
func (g *Governor) backoff(h http.Header, attempt int) (d time.Duration, ok bool) {
|
||||
v := strings.TrimSpace(h.Get("Retry-After"))
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
return time.Duration(n) * time.Second, time.Duration(n)*time.Second <= g.p.MaxDelay
|
||||
}
|
||||
if t, err := http.ParseTime(v); err == nil {
|
||||
return max(time.Until(t), 0), time.Until(t) <= g.p.MaxDelay
|
||||
}
|
||||
if d = g.p.BaseDelay << attempt; d <= 0 || d > g.p.MaxDelay {
|
||||
d = g.p.MaxDelay
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
|
||||
func sleep(ctx context.Context, d time.Duration) error {
|
||||
if d <= 0 {
|
||||
return ctx.Err()
|
||||
}
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-t.C:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// report returns one noise line per active host and starts a new minute.
|
||||
func (g *Governor) report() []string {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
var out []string
|
||||
for key, h := range g.hosts {
|
||||
if h.sent == 0 && h.dupes == 0 {
|
||||
continue
|
||||
}
|
||||
h.peakRPM = max(h.peakRPM, h.sent)
|
||||
out = append(out, fmt.Sprintf("[egress] noise profile=%s host=%s rpm=%d peak_rpm=%d peak_inflight=%d duplicates_avoided=%d",
|
||||
g.p.Name, key, h.sent, h.peakRPM, h.peakInflight, h.dupes))
|
||||
h.sent, h.dupes, h.peakInflight = 0, 0, h.inflight
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
var errNotShared = errors.New("egress: response not shared")
|
||||
|
||||
// share runs fn once for the concurrent callers of key and keeps its value for
|
||||
// the ttl fn returns. A caller that waited on a flight that failed for a reason
|
||||
// of the leader's own (its context, a response too large to share) runs fn
|
||||
// itself, uncached; fn paces its own send (transport.send goes through
|
||||
// acquire), so that request is paced like any other.
|
||||
func (g *Governor) share(ctx context.Context, host, key string, fn func() (any, time.Duration, error)) (any, error) {
|
||||
g.mu.Lock()
|
||||
f := g.flights[key]
|
||||
if f != nil && !f.exp.IsZero() && time.Now().After(f.exp) {
|
||||
f = nil
|
||||
}
|
||||
if f != nil {
|
||||
g.mu.Unlock()
|
||||
select {
|
||||
case <-f.done:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if errors.Is(f.err, errNotShared) || errors.Is(f.err, context.Canceled) || errors.Is(f.err, context.DeadlineExceeded) {
|
||||
v, _, err := fn()
|
||||
return v, err
|
||||
}
|
||||
h := g.host(host)
|
||||
g.mu.Lock()
|
||||
h.dupes++
|
||||
g.mu.Unlock()
|
||||
return f.val, f.err
|
||||
}
|
||||
f = &flight{done: make(chan struct{})}
|
||||
g.flights[key] = f
|
||||
for k, o := range g.flights {
|
||||
if !o.exp.IsZero() && time.Now().After(o.exp) {
|
||||
delete(g.flights, k)
|
||||
}
|
||||
}
|
||||
g.mu.Unlock()
|
||||
v, ttl, err := fn()
|
||||
f.val, f.err = v, err
|
||||
g.mu.Lock()
|
||||
if err == nil && ttl > 0 {
|
||||
f.exp = time.Now().Add(ttl)
|
||||
} else if g.flights[key] == f {
|
||||
delete(g.flights, key)
|
||||
}
|
||||
close(f.done)
|
||||
g.mu.Unlock()
|
||||
return v, err
|
||||
}
|
||||
|
||||
// taskKey identifies the task (or conversation) of ctx; "" when there is none,
|
||||
// and then nothing is coalesced or cached.
|
||||
func taskKey(ctx context.Context) string {
|
||||
s, _ := intercept.TargetScopeFromContext(ctx)
|
||||
if s.TaskID != "" {
|
||||
return "task:" + s.TaskID
|
||||
}
|
||||
if s.ConversationID != 0 {
|
||||
return "conv:" + strconv.FormatInt(s.ConversationID, 10)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Do runs fn as one send to u for a caller whose HTTP client is not ours
|
||||
// (WebFetch): only the rate, jitter and concurrency of u's host. Nothing is
|
||||
// coalesced or cached: each WebFetch call carries its own proxy and its own
|
||||
// redirect and dial gate, which a shared key would have to fingerprint
|
||||
// completely; running every call keeps every gate decision, at the cost of a
|
||||
// duplicate request now and then. fn hides the response, so a 429/503 is not
|
||||
// retried here. Off, fn just runs.
|
||||
func Do[T any](g *Governor, ctx context.Context, u *url.URL, fn func() (T, error)) (T, error) {
|
||||
if g.off() {
|
||||
return fn()
|
||||
}
|
||||
release, err := g.acquire(ctx, HostKey(u))
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
defer release()
|
||||
return fn()
|
||||
}
|
||||
|
||||
// Transport returns base (nil: http.DefaultTransport) governed by g; off, base
|
||||
// itself. namespace names the client (a custom tool's key, "enrich"): it is
|
||||
// part of the cache key, so clients with other proxies, gates or pins never
|
||||
// share a response.
|
||||
func (g *Governor) Transport(namespace string, base http.RoundTripper) http.RoundTripper {
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
if g.off() {
|
||||
return base
|
||||
}
|
||||
return &transport{g, namespace, base}
|
||||
}
|
||||
|
||||
type transport struct {
|
||||
g *Governor
|
||||
namespace string
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
const maxSharedBody = 2 << 20 // a larger response is neither coalesced nor cached
|
||||
|
||||
func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if ua := t.g.p.UserAgent; ua != "" && req.Header.Get("User-Agent") == "" {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set("User-Agent", ua)
|
||||
}
|
||||
task := taskKey(req.Context())
|
||||
if task == "" || req.Method != http.MethodGet || (req.Body != nil && req.Body != http.NoBody) {
|
||||
return t.send(req)
|
||||
}
|
||||
// The key holds the headers: a request with other credentials is its own.
|
||||
var hdr bytes.Buffer
|
||||
_ = req.Header.Write(&hdr)
|
||||
var own *http.Response // the leader's response when too large to share
|
||||
key := t.namespace + "\x00" + task + "\x00GET " + req.URL.String() + "\x00" + hdr.String()
|
||||
v, err := t.g.share(req.Context(), HostKey(req.URL), key, func() (any, time.Duration, error) {
|
||||
resp, err := t.send(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSharedBody+1))
|
||||
if err != nil || len(body) > maxSharedBody {
|
||||
resp.Body = struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{io.MultiReader(bytes.NewReader(body), resp.Body), resp.Body}
|
||||
own = resp
|
||||
return nil, 0, errNotShared
|
||||
}
|
||||
resp.Body.Close()
|
||||
ttl := t.g.p.CacheTTL
|
||||
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
|
||||
ttl = 0 // coalesced, never cached
|
||||
}
|
||||
return &stored{resp, body}, ttl, nil
|
||||
})
|
||||
if own != nil {
|
||||
return own, nil // too large to share: this caller's own response
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := v.(*stored)
|
||||
r := *s.resp
|
||||
r.Header, r.Body, r.ContentLength, r.Request = s.resp.Header.Clone(), io.NopCloser(bytes.NewReader(s.body)), int64(len(s.body)), req
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// stored is a shared response; each caller gets a copy.
|
||||
type stored struct {
|
||||
resp *http.Response
|
||||
body []byte
|
||||
}
|
||||
|
||||
// send makes one request within the host's limits. A 429/503 pauses the host
|
||||
// and, for a bodiless GET/HEAD/OPTIONS, is retried up to MaxRetries times; a
|
||||
// request that may change state is never retried. The slots are held until
|
||||
// the response body is closed.
|
||||
func (t *transport) send(req *http.Request) (*http.Response, error) {
|
||||
host := HostKey(req.URL)
|
||||
for attempt := 0; ; attempt++ {
|
||||
release, err := t.g.acquire(req.Context(), host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := t.base.RoundTrip(req)
|
||||
if err != nil {
|
||||
release()
|
||||
return nil, err
|
||||
}
|
||||
resp.Body = &releaseBody{resp.Body, release}
|
||||
if resp.StatusCode != http.StatusTooManyRequests && resp.StatusCode != http.StatusServiceUnavailable {
|
||||
return resp, nil
|
||||
}
|
||||
d, ok := t.g.backoff(resp.Header, attempt)
|
||||
t.g.mu.Lock()
|
||||
h := t.g.hosts[host]
|
||||
if until := time.Now().Add(min(d, t.g.p.MaxDelay)); until.After(h.notBefore) {
|
||||
h.notBefore = until
|
||||
}
|
||||
t.g.mu.Unlock()
|
||||
idempotent := req.Method == http.MethodGet || req.Method == http.MethodHead || req.Method == http.MethodOptions
|
||||
if !ok || !idempotent || (req.Body != nil && req.Body != http.NoBody) || attempt >= t.g.p.MaxRetries {
|
||||
return resp, nil
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
type releaseBody struct {
|
||||
io.ReadCloser
|
||||
release func()
|
||||
}
|
||||
|
||||
func (b *releaseBody) Close() error {
|
||||
err := b.ReadCloser.Close()
|
||||
b.release()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package egress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
// open is a profile that does not limit, for the tests of one lever.
|
||||
var open = Profile{Name: "test", RPS: 1000, Burst: 1000, MaxPerHost: 100, MaxGlobal: 100, CacheTTL: time.Minute, MaxRetries: 2, BaseDelay: 10 * time.Millisecond, MaxDelay: 2 * time.Second}
|
||||
|
||||
func task(id string) context.Context {
|
||||
return intercept.WithTargetScope(context.Background(), intercept.TargetScope{TaskID: id})
|
||||
}
|
||||
|
||||
// server counts its hits and the peak of requests in flight; h, if set, answers.
|
||||
type server struct {
|
||||
*httptest.Server
|
||||
hits, inflight, peak atomic.Int64
|
||||
}
|
||||
|
||||
func newServer(t *testing.T, delay time.Duration, h func(n int64, w http.ResponseWriter, r *http.Request)) *server {
|
||||
s := &server{}
|
||||
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n, cur := s.hits.Add(1), s.inflight.Add(1)
|
||||
for p := s.peak.Load(); cur > p && !s.peak.CompareAndSwap(p, cur); p = s.peak.Load() {
|
||||
}
|
||||
time.Sleep(delay)
|
||||
s.inflight.Add(-1)
|
||||
if h != nil {
|
||||
h(n, w, r)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
}))
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
func do(t *testing.T, g *Governor, ctx context.Context, method, u string) (int, string) {
|
||||
req, _ := http.NewRequestWithContext(ctx, method, u, nil)
|
||||
resp, err := (&http.Client{Transport: g.Transport("test", nil)}).Do(req)
|
||||
if err != nil {
|
||||
t.Errorf("%s %s: %v", method, u, err)
|
||||
return 0, ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
func parallel(n int, f func(i int)) {
|
||||
var wg sync.WaitGroup
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); f(i) }()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestOffIsStrictPassthrough(t *testing.T) {
|
||||
g := New(Profile{Name: "off"})
|
||||
if g.Transport("test", http.DefaultTransport) != http.DefaultTransport {
|
||||
t.Fatal("off: Transport must return the base transport itself")
|
||||
}
|
||||
s := newServer(t, 50*time.Millisecond, nil)
|
||||
start := time.Now()
|
||||
parallel(20, func(int) { do(t, g, task("t1"), "GET", s.URL) })
|
||||
if d := time.Since(start); s.hits.Load() != 20 || s.peak.Load() < 10 || d > time.Second {
|
||||
t.Fatalf("off: hits %d, peak %d, %s; want 20 parallel hits, no delay", s.hits.Load(), s.peak.Load(), d)
|
||||
}
|
||||
var calls atomic.Int64
|
||||
u, _ := url.Parse(s.URL)
|
||||
for range 2 {
|
||||
_, _ = Do(g, task("t1"), u, func() (int, error) { calls.Add(1); return 0, nil })
|
||||
}
|
||||
if calls.Load() != 2 || len(g.report()) != 0 {
|
||||
t.Fatalf("off: Do calls = %d, report %v; want 2 calls, no counter", calls.Load(), g.report())
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfilesRespectRateBurstConcurrency sends burst+extra requests at once:
|
||||
// the burst leaves at once, the rest at RPS, never more than MaxPerHost at a time.
|
||||
func TestProfilesRespectRateBurstConcurrency(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
extra int
|
||||
}{{"quiet", 2}, {"balanced", 5}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := Profiles[tc.name]
|
||||
g := New(p)
|
||||
s := newServer(t, 20*time.Millisecond, nil)
|
||||
n := p.Burst + tc.extra
|
||||
start := time.Now()
|
||||
parallel(n, func(int) { do(t, g, context.Background(), "GET", s.URL) })
|
||||
d := time.Since(start)
|
||||
if rate := float64(tc.extra) / d.Seconds(); rate > p.RPS*1.1 || d > 5*time.Second {
|
||||
t.Errorf("%d requests in %s: %.1f req/s after the burst, want <= %.0f", n, d, rate, p.RPS)
|
||||
}
|
||||
if s.hits.Load() != int64(n) || s.peak.Load() > int64(p.MaxPerHost) {
|
||||
t.Errorf("hits %d, peak in flight %d; want %d, <= %d", s.hits.Load(), s.peak.Load(), n, p.MaxPerHost)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoff(t *testing.T) {
|
||||
g := New(open)
|
||||
s := newServer(t, 0, func(n int64, w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/once" && n == 1:
|
||||
w.Header().Set("Retry-After", "1")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
case r.URL.Path == "/far":
|
||||
w.Header().Set("Retry-After", "3600")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
case r.URL.Path == "/always":
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
})
|
||||
start := time.Now()
|
||||
if code, _ := do(t, g, context.Background(), "GET", s.URL+"/once"); code != 200 || s.hits.Load() != 2 || time.Since(start) < 950*time.Millisecond {
|
||||
t.Fatalf("Retry-After 1: status %d, hits %d, %s", code, s.hits.Load(), time.Since(start))
|
||||
}
|
||||
if code, _ := do(t, g, context.Background(), "GET", s.URL+"/always"); code != 503 || s.hits.Load() != 5 {
|
||||
t.Fatalf("bounded: status %d, hits %d; want 503 after 1+2 more hits", code, s.hits.Load())
|
||||
}
|
||||
if code, _ := do(t, g, context.Background(), "POST", s.URL+"/always"); code != 503 || s.hits.Load() != 6 {
|
||||
t.Fatalf("POST: status %d, hits %d; want no retry", code, s.hits.Load())
|
||||
}
|
||||
if code, _ := do(t, g, context.Background(), "GET", s.URL+"/far"); code != 429 || s.hits.Load() != 7 {
|
||||
t.Fatalf("Retry-After beyond MaxDelay: status %d, hits %d; want no retry", code, s.hits.Load())
|
||||
}
|
||||
h := http.Header{"Retry-After": {time.Now().Add(2 * time.Second).UTC().Format(http.TimeFormat)}}
|
||||
if d, ok := g.backoff(h, 0); !ok || d <= 0 || d > 2*time.Second {
|
||||
t.Fatalf("HTTP-date Retry-After: %s, %v", d, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeAndCache(t *testing.T) {
|
||||
p := open
|
||||
p.CacheTTL = 200 * time.Millisecond
|
||||
g := New(p)
|
||||
s := newServer(t, 50*time.Millisecond, nil)
|
||||
parallel(5, func(int) {
|
||||
if _, body := do(t, g, task("t1"), "GET", s.URL); body != "ok" {
|
||||
t.Errorf("body = %q", body)
|
||||
}
|
||||
})
|
||||
do(t, g, task("t1"), "GET", s.URL) // cached
|
||||
if s.hits.Load() != 1 {
|
||||
t.Fatalf("coalesced and cached GETs: hits %d, want 1", s.hits.Load())
|
||||
}
|
||||
do(t, g, task("t2"), "GET", s.URL) // another task
|
||||
do(t, g, context.Background(), "GET", s.URL) // no task
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
do(t, g, task("t1"), "GET", s.URL) // expired
|
||||
if s.hits.Load() != 4 {
|
||||
t.Fatalf("other task, no task, expired: hits %d, want 4", s.hits.Load())
|
||||
}
|
||||
methods := []string{"POST", "PUT", "PATCH", "DELETE"}
|
||||
parallel(8, func(i int) { do(t, g, task("t1"), methods[i/2], s.URL) })
|
||||
if s.hits.Load() != 12 {
|
||||
t.Fatalf("mutating methods: hits %d, want 12", s.hits.Load())
|
||||
}
|
||||
if r := g.report(); len(r) != 1 || !strings.Contains(r[0], "rpm=12 ") || !strings.Contains(r[0], "duplicates_avoided=5") {
|
||||
t.Fatalf("noise counter: %v", r)
|
||||
}
|
||||
// WebFetch path: Do paces, and neither coalesces nor caches.
|
||||
var calls atomic.Int64
|
||||
u, _ := url.Parse(s.URL)
|
||||
fn := func() (string, error) { calls.Add(1); time.Sleep(50 * time.Millisecond); return "r", nil }
|
||||
parallel(3, func(int) {
|
||||
if v, _ := Do(g, task("t1"), u, fn); v != "r" {
|
||||
t.Errorf("value = %q", v)
|
||||
}
|
||||
})
|
||||
_, _ = Do(g, task("t1"), u, fn)
|
||||
if calls.Load() != 4 {
|
||||
t.Fatalf("Do calls = %d, want 4", calls.Load())
|
||||
}
|
||||
// Two clients (namespaces) with the same task, URL and headers never share
|
||||
// a response; the same namespace again is a cache hit.
|
||||
for _, ns := range []string{"tool-a", "tool-b", "tool-a"} {
|
||||
req, _ := http.NewRequestWithContext(task("t9"), "GET", s.URL+"/ns", nil)
|
||||
if resp, err := (&http.Client{Transport: g.Transport(ns, nil)}).Do(req); err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
if s.hits.Load() != 14 {
|
||||
t.Fatalf("namespaces: hits %d, want 14 (one per namespace, then a cache hit)", s.hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// A response too large to share reaches its caller in full and frees its
|
||||
// slots: with one slot per host, three GETs in a row each complete.
|
||||
func TestLargeResponseNotSharedReleasesSlots(t *testing.T) {
|
||||
p := open
|
||||
p.MaxPerHost = 1
|
||||
g := New(p)
|
||||
big := strings.Repeat("x", 3<<20)
|
||||
s := newServer(t, 0, func(_ int64, w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, big) })
|
||||
for i := range 3 {
|
||||
ctx, cancel := context.WithTimeout(task("t1"), 5*time.Second)
|
||||
if _, body := do(t, g, ctx, "GET", s.URL); len(body) != len(big) {
|
||||
t.Fatalf("GET %d: body %d bytes, want %d", i+1, len(body), len(big))
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
if s.hits.Load() != 3 {
|
||||
t.Fatalf("hits %d, want 3", s.hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestJitterBounded(t *testing.T) {
|
||||
for name, want := range map[string]time.Duration{"quiet": 250 * time.Millisecond, "balanced": 40 * time.Millisecond, "aggressive": 5 * time.Millisecond} {
|
||||
g := New(Profiles[name])
|
||||
if b := g.jitterBound(); b != want {
|
||||
t.Fatalf("%s: bound %s, want %s", name, b, want)
|
||||
}
|
||||
var nonzero bool
|
||||
for range 1000 {
|
||||
d := g.jitter()
|
||||
if d < 0 || d > want {
|
||||
t.Fatalf("%s: jitter %s outside [0, %s]", name, d, want)
|
||||
}
|
||||
nonzero = nonzero || d > 0
|
||||
}
|
||||
if !nonzero {
|
||||
t.Fatalf("%s: jitter always 0", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAgentAndEnv(t *testing.T) {
|
||||
var got atomic.Value
|
||||
s := newServer(t, 0, func(_ int64, _ http.ResponseWriter, r *http.Request) { got.Store(r.UserAgent()) })
|
||||
p := open
|
||||
do(t, New(p), context.Background(), "GET", s.URL)
|
||||
if ua := got.Load().(string); !strings.HasPrefix(ua, "Go-http-client/") {
|
||||
t.Fatalf("unset: User-Agent = %q, want the Go default", ua)
|
||||
}
|
||||
p.UserAgent = "engagement-ua"
|
||||
do(t, New(p), context.Background(), "GET", s.URL)
|
||||
if ua := got.Load().(string); ua != "engagement-ua" {
|
||||
t.Fatalf("set: User-Agent = %q", ua)
|
||||
}
|
||||
if p := ProfileFromEnv(); p.RPS != 0 || p.Name != "off" {
|
||||
t.Fatalf("default profile: %+v, want off", p)
|
||||
}
|
||||
t.Setenv("ARTEX_NOISE_PROFILE", "Balanced")
|
||||
t.Setenv("ARTEX_TARGET_USER_AGENT", "x/1")
|
||||
if p := ProfileFromEnv(); p.Name != "balanced" || p.RPS != 5 || p.UserAgent != "x/1" {
|
||||
t.Fatalf("balanced from env: %+v", p)
|
||||
}
|
||||
t.Setenv("ARTEX_NOISE_PROFILE", "loud")
|
||||
if p := ProfileFromEnv(); p.RPS != 0 {
|
||||
t.Fatalf("unknown profile: %+v, want off", p)
|
||||
}
|
||||
for in, want := range map[string]string{"http://Example.COM:80/x": "example.com", "https://a.test:8443/": "a.test:8443"} {
|
||||
if u, _ := url.Parse(in); HostKey(u) != want {
|
||||
t.Errorf("HostKey(%s) = %q, want %q", in, HostKey(u), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -21,6 +21,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/egress"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
@@ -97,7 +98,8 @@ func New(as *db.AssetStore, proxy func() string, workers int) *Engine {
|
||||
// intercept.PinnedDialer, under the target scope of the request's context: a
|
||||
// probe without a scope is refused. When the scope's gate is on, the probe
|
||||
// connects to the target itself, without the proxy, so that the dialer decides
|
||||
// on the target and connects only to the addresses a pin covers.
|
||||
// on the target and connects only to the addresses a pin covers. Every request
|
||||
// goes through the shared egress governor (the noise profile; off by default).
|
||||
func buildClient(proxy func() string) *http.Client {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
@@ -118,7 +120,7 @@ func buildClient(proxy func() string) *http.Client {
|
||||
}
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: tr,
|
||||
Transport: egress.Shared().Transport("enrich", tr),
|
||||
Timeout: httpTimeout,
|
||||
// cap redirects; keep them within scope by re-checking at probe time
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
@@ -245,7 +247,6 @@ func (e *Engine) doHTTP(ctx context.Context, id int64, rawURL string) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("User-Agent", "artex-enrich/1.0")
|
||||
resp, err := e.client.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
+21
-7
@@ -60,6 +60,15 @@ func workerFrom(ctx context.Context) string {
|
||||
return strings.TrimSpace(worker)
|
||||
}
|
||||
|
||||
type usageSinkContextKey struct{}
|
||||
|
||||
// WithUsageSink attaches a callback that receives the usage of every metered
|
||||
// call made under ctx — the same value, at the same point, as the llm_usage row.
|
||||
// The task runtime uses it to meter run budgets.
|
||||
func WithUsageSink(ctx context.Context, sink func(llm.Usage)) context.Context {
|
||||
return context.WithValue(ctx, usageSinkContextKey{}, sink)
|
||||
}
|
||||
|
||||
// Recorder wraps an llm.Provider and records every completion call.
|
||||
type Recorder struct {
|
||||
inner llm.Provider
|
||||
@@ -168,7 +177,7 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S
|
||||
status = "error"
|
||||
}
|
||||
// Lightweight metering row — always written.
|
||||
r.recordUsage(taskID, expID, worker, usage, int(time.Since(start).Milliseconds()), status)
|
||||
r.recordUsage(ctx, taskID, expID, worker, usage, int(time.Since(start).Milliseconds()), status)
|
||||
// Heavy trace row — only when body recording is on.
|
||||
if recordBodies {
|
||||
r.record(req, session, taskID, worker, reqBody, capt, start, textBuf.String(), thinkingBuf.String(), usage, stopReason, err)
|
||||
@@ -246,7 +255,7 @@ func (r *Recorder) Complete(ctx context.Context, req llm.CompletionRequest) (llm
|
||||
if err != nil {
|
||||
status = "error"
|
||||
}
|
||||
r.recordUsage(taskID, expID, worker, usage, int(time.Since(start).Milliseconds()), status)
|
||||
r.recordUsage(ctx, taskID, expID, worker, usage, int(time.Since(start).Milliseconds()), status)
|
||||
if recordBodies {
|
||||
r.record(req, session, taskID, worker, reqBody, capt, start, msg.Text(), thinkingText(msg), usage, stopReason, err)
|
||||
}
|
||||
@@ -264,15 +273,20 @@ func thinkingText(msg llm.Message) string {
|
||||
}
|
||||
|
||||
// recordUsage appends one lightweight metering row to llm_usage (no bodies). Skips
|
||||
// zero-token calls with no model, which carry nothing worth metering.
|
||||
func (r *Recorder) recordUsage(taskID string, expID int64, worker string, usage llm.Usage, latencyMs int, status string) {
|
||||
if r.pg == nil {
|
||||
return
|
||||
}
|
||||
// zero-token calls with no model, which carry nothing worth metering. The ctx
|
||||
// usage sink (if any) sees these calls even when no PostgreSQL recorder is set
|
||||
// (r.pg == nil), so a budget also counts calls that are not persisted.
|
||||
func (r *Recorder) recordUsage(ctx context.Context, taskID string, expID int64, worker string, usage llm.Usage, latencyMs int, status string) {
|
||||
if r.model == "" && usage.InputTokens == 0 && usage.OutputTokens == 0 &&
|
||||
usage.CacheReadTokens == 0 && usage.CacheWriteTokens == 0 {
|
||||
return
|
||||
}
|
||||
if sink, _ := ctx.Value(usageSinkContextKey{}).(func(llm.Usage)); sink != nil {
|
||||
sink(usage)
|
||||
}
|
||||
if r.pg == nil {
|
||||
return
|
||||
}
|
||||
err := r.pg.InsertLLMUsage(&db.LLMUsage{
|
||||
TaskID: taskID,
|
||||
ExplorationID: expID,
|
||||
|
||||
@@ -68,6 +68,32 @@ func TestCompleteForwardsAtomicResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The usage sink sees each metered call once, with the usage the llm_usage row gets.
|
||||
func TestUsageSinkSeesMeteredCalls(t *testing.T) {
|
||||
var got []llm.Usage
|
||||
ctx := WithUsageSink(context.Background(), func(u llm.Usage) { got = append(got, u) })
|
||||
recorder := Wrap(meteredStreamProvider{}, nil, "model", "profile", "", "", func() bool { return false })
|
||||
for _, err := range recorder.Stream(ctx, llm.CompletionRequest{}) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, _, _, err := recorder.Complete(ctx, llm.CompletionRequest{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []llm.Usage{{InputTokens: 23, OutputTokens: 5}, {InputTokens: 7, OutputTokens: 3}}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("sink saw %+v, want %+v", got, want)
|
||||
}
|
||||
// A call that gets no llm_usage row (no model, zero usage) is not seen either.
|
||||
got = nil
|
||||
empty := Wrap(completeProvider{}, nil, "", "", "", "", nil)
|
||||
empty.recordUsage(ctx, "", 0, "", llm.Usage{}, 0, "ok")
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("sink saw an unmetered call: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type meteredStreamProvider struct{ completeProvider }
|
||||
|
||||
func (meteredStreamProvider) Stream(context.Context, llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
|
||||
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Print the body of the CHANGELOG section "## [<version>]": the lines after that
|
||||
# heading up to (not including) the next "## [" heading, with leading and
|
||||
# trailing blank lines removed. The version is matched literally (no leading v).
|
||||
# Exit 1 with nothing on stdout when the section is missing or empty.
|
||||
#
|
||||
# Usage: scripts/extract-changelog-section.sh <version> [changelog] (default CHANGELOG.md)
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -lt 1 ] || [ $# -gt 2 ] || [ -z "$1" ]; then
|
||||
echo "usage: $0 <version> [changelog]" >&2
|
||||
exit 2
|
||||
fi
|
||||
version=$1
|
||||
changelog=${2:-CHANGELOG.md}
|
||||
|
||||
body=$(awk -v head="## [$version]" '
|
||||
{ sub(/\r$/, "") } # tolerate a CRLF checkout
|
||||
index($0, "## [") == 1 {
|
||||
if (found) exit
|
||||
rest = substr($0, length(head) + 1)
|
||||
if (substr($0, 1, length(head)) == head && (rest == "" || rest ~ /^[ \t]/)) { found = 1; next }
|
||||
}
|
||||
found { print }
|
||||
' "$changelog")
|
||||
|
||||
# Trim leading blank lines ($(...) already drops the trailing ones).
|
||||
body=$(printf '%s\n' "$body" | sed '/./,$!d')
|
||||
|
||||
if [ -z "$body" ]; then
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$body"
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests scripts/extract-changelog-section.sh against a fake CHANGELOG in a temp dir.
|
||||
set -euo pipefail
|
||||
|
||||
extract="$(cd "$(dirname "$0")" && pwd)/extract-changelog-section.sh"
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
cat > "$tmp/CHANGELOG.md" <<'EOF'
|
||||
# Changelog
|
||||
|
||||
## [1.2.30] - 2030-01-03
|
||||
|
||||
Not this one.
|
||||
|
||||
## [1.2.3] - 2030-01-02
|
||||
|
||||
Summary line.
|
||||
|
||||
### Added
|
||||
|
||||
- **Thing**: did a thing (#1).
|
||||
|
||||
## [1.2.2] - 2030-01-01
|
||||
|
||||
- Older entry.
|
||||
EOF
|
||||
|
||||
expected='Summary line.
|
||||
|
||||
### Added
|
||||
|
||||
- **Thing**: did a thing (#1).'
|
||||
|
||||
fail=0
|
||||
|
||||
if out=$(bash "$extract" 1.2.3 "$tmp/CHANGELOG.md") && [ "$out" = "$expected" ]; then
|
||||
echo "PASS: section [1.2.3] found, body returned exactly"
|
||||
else
|
||||
echo "FAIL: section [1.2.3]; got:"; printf '%s\n' "${out-}"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if out=$(bash "$extract" 9.9.9 "$tmp/CHANGELOG.md"); then
|
||||
echo "FAIL: missing section [9.9.9] exited 0; got:"; printf '%s\n' "$out"
|
||||
fail=1
|
||||
elif [ -n "$out" ]; then
|
||||
echo "FAIL: missing section [9.9.9] printed output:"; printf '%s\n' "$out"
|
||||
fail=1
|
||||
else
|
||||
echo "PASS: missing section [9.9.9] exits non-zero with no output"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/egress"
|
||||
"github.com/Autumn-27/artex/guard"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"github.com/Autumn-27/norma/permission"
|
||||
@@ -457,9 +458,13 @@ func (s *Server) runHTTPTool(ctx context.Context, key string, execRaw json.RawMe
|
||||
client := &http.Client{Timeout: timeoutOr(spec.TimeoutMs, 30000)}
|
||||
// Every redirect hop goes through the target gate, as the rendered URL did.
|
||||
client.CheckRedirect = intercept.RedirectCheck(key)
|
||||
// The shared egress governor paces every request and redirect hop per host
|
||||
// under the noise profile (off by default: the base transport itself).
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
if tr := s.httpToolTransport(ctx, key, spec); tr != nil {
|
||||
client.Transport = tr
|
||||
base = tr
|
||||
}
|
||||
client.Transport = egress.Shared().Transport(key, base)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return actool.Errorf("request failed: " + err.Error()), nil
|
||||
|
||||
+30
-1
@@ -146,6 +146,7 @@ type Engine struct {
|
||||
stamped sync.Map // taskID -> bool, whether first_run_at has been stamped (once per process)
|
||||
inflight sync.Map // taskID -> *int64, count of in-flight planner.Plan + worker.Execute (for drain)
|
||||
coordStarted sync.Map // taskID -> bool, whether the deadline coordinator has started (deduplicated across Run/reload)
|
||||
budgets sync.Map // taskID -> *agent.BudgetLedger, run budget meter (nil = no budget configured; see engine_budget.go)
|
||||
|
||||
// resolve returns a task's dedicated planner/worker (wired by the server as the
|
||||
// authoritative task-router). nil,nil means this task is deliberately unavailable
|
||||
@@ -289,6 +290,7 @@ func (e *Engine) StopTask(taskID string) {
|
||||
e.stamped.Delete(taskID)
|
||||
e.inflight.Delete(taskID)
|
||||
e.coordStarted.Delete(taskID)
|
||||
e.budgets.Delete(taskID)
|
||||
e.deleteMu.Lock()
|
||||
e.deleting.Delete(taskID)
|
||||
e.deleteMu.Unlock()
|
||||
@@ -532,6 +534,9 @@ type steerHooks struct {
|
||||
limit int
|
||||
// label looks like "worker-1 · #42"; used only for logging.
|
||||
label string
|
||||
// budget meters this intent's run (nil = no budget). Once exhausted, every tool
|
||||
// but the write-back ones is refused with the wrap-up instruction.
|
||||
budget *agent.IntentMeter
|
||||
}
|
||||
|
||||
// Default number of reruns for idle turns (thinking only, neither body text nor tool
|
||||
@@ -573,6 +578,9 @@ func (h steerHooks) PreToolUse(ctx context.Context, name string, input []byte) (
|
||||
return true, "[Planner real-time steering] " + msg +
|
||||
"\n(This is the planner's immediate instruction for this intent; the tool call was not executed — adjust your next step accordingly. If it conflicts with your current plan, this takes precedence.)", nil
|
||||
}
|
||||
if h.budget != nil && agent.BudgetWrapupBlocks(name) && h.budget.Exhausted() {
|
||||
return true, agent.WorkerBudgetWrapup, nil
|
||||
}
|
||||
if h.inner != nil {
|
||||
return h.inner.PreToolUse(ctx, name, input)
|
||||
}
|
||||
@@ -879,6 +887,11 @@ func (e *Engine) plannerLoop(ctx context.Context, t *Task) {
|
||||
}
|
||||
return // the goalless branch never enters planner.Plan
|
||||
}
|
||||
// Run budget (off by default): no worker affordable any more → the forced
|
||||
// wrap-up (once) instead of a normal round.
|
||||
if e.budgetStop(ctx, t) {
|
||||
return
|
||||
}
|
||||
// quiet-mode (#79): an edge round is redundant while work is in flight
|
||||
// and the open-goal set is unchanged since the last completed round —
|
||||
// there is no new decision for the plan to make. Drain the triggers so
|
||||
@@ -910,7 +923,7 @@ func (e *Engine) plannerLoop(ctx context.Context, t *Task) {
|
||||
// coalesces a burst; empty for time/heartbeat wakes).
|
||||
triggers := t.drainTriggers()
|
||||
taskIDInt, _ := strconv.ParseInt(t.ID, 10, 64)
|
||||
pctx := e.plannerGateContext(ectx, t, intercept.DefaultTargetWaitBound)
|
||||
pctx := e.budgetRoundCtx(e.plannerGateContext(ectx, t, intercept.DefaultTargetWaitBound), t, false)
|
||||
e.BeginLLMCall(t.ID)
|
||||
met, reason, err := planner.Plan(pctx, taskIDInt, e.m.assets, t.Store, t.Goal, triggers, t.TargetGuard.Hooks(), emit)
|
||||
e.EndLLMCall(t.ID)
|
||||
@@ -936,6 +949,9 @@ func (e *Engine) plannerLoop(ctx context.Context, t *Task) {
|
||||
if fp, err := t.Store.OpenGoalFingerprint(); err == nil {
|
||||
e.goalFP.Store(t.ID, fp)
|
||||
}
|
||||
if e.budgetBlocksClaim(t.ID) {
|
||||
t.Notify() // this round crossed the budget: settle on the next round, not at the next heartbeat
|
||||
}
|
||||
e.touch(t.ID)
|
||||
}
|
||||
|
||||
@@ -1041,6 +1057,12 @@ func (e *Engine) workerLoop(ctx context.Context, t *Task, name string) {
|
||||
}
|
||||
continue // task timeout settlement in progress: claim no new intents (running ones settle themselves; the coordinator waits for them to drain)
|
||||
}
|
||||
if e.budgetBlocksClaim(t.ID) {
|
||||
if sleepCtx(ctx, 1000*time.Millisecond) {
|
||||
return
|
||||
}
|
||||
continue // run budget below one worker's estimate: dispatch no new worker (the planner loop settles the task)
|
||||
}
|
||||
if isTerminalStatus(e.m.TaskStatus(t.ID)) {
|
||||
if sleepCtx(ctx, 1000*time.Millisecond) {
|
||||
return
|
||||
@@ -1116,6 +1138,9 @@ func (e *Engine) runIntent(ctx context.Context, t *Task, name string, worker *ag
|
||||
label := fmt.Sprintf("%s · #%d", name, iid)
|
||||
workCtx = intercept.WithTaskContext(workCtx, t.ID, label, taskEmit)
|
||||
workCtx = intercept.WithTargetScope(workCtx, t.targetScope(intercept.DefaultTargetWaitBound))
|
||||
// Run budget (off by default): meter this run's calls; its effective budget is
|
||||
// min(intent budget, task remaining above the wrap-up reserve).
|
||||
workCtx, meter := e.budgetIntent(workCtx, t.ID)
|
||||
// nudges deliberately sits outside the model_error rerun loop: the idle-turn rerun
|
||||
// cap is a per-intent total, and one rerun round must not reset it back to zero.
|
||||
hooks := steerHooks{
|
||||
@@ -1124,6 +1149,7 @@ func (e *Engine) runIntent(ctx context.Context, t *Task, name string, worker *ag
|
||||
nudges: &atomic.Int64{},
|
||||
limit: e.emptyTurnNudgeLimit(),
|
||||
label: label,
|
||||
budget: meter,
|
||||
}
|
||||
wTaskID, _ := strconv.ParseInt(t.ID, 10, 64)
|
||||
e.BeginLLMCall(t.ID)
|
||||
@@ -1157,6 +1183,9 @@ func (e *Engine) runIntent(ctx context.Context, t *Task, name string, worker *ag
|
||||
e.EndLLMCall(t.ID)
|
||||
}
|
||||
stopHeartbeat() // no longer busy: the state writes below follow within the just-renewed claim
|
||||
if meter != nil {
|
||||
meter.Finish() // feeds the per-worker cost estimate
|
||||
}
|
||||
// Capture kill state before detachWork cancels workCtx. kill = this work's
|
||||
// ctx was cancelled (planner kill_work) while the TASK ctx kept running; a
|
||||
// pause cancels the task ctx (ectx) instead. Checking workCtx.Err() AFTER
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/llmrec"
|
||||
)
|
||||
|
||||
// Hierarchical run budgets (agent/budget.go), off unless an ARTEX_*_BUDGET_* env
|
||||
// var is set. Every helper here is a no-op when budgetFor returns nil.
|
||||
|
||||
// budgetFor returns the task's budget ledger, created on first use from the env;
|
||||
// nil when no budget is configured (passthrough).
|
||||
func (e *Engine) budgetFor(taskID string) *agent.BudgetLedger {
|
||||
if v, ok := e.budgets.Load(taskID); ok {
|
||||
return v.(*agent.BudgetLedger)
|
||||
}
|
||||
v, _ := e.budgets.LoadOrStore(taskID, agent.NewBudgetLedger(agent.BudgetsFromEnv()))
|
||||
return v.(*agent.BudgetLedger)
|
||||
}
|
||||
|
||||
// budgetStop is the enforcement point of a planner round: it logs the remaining
|
||||
// budget and, once no worker can be dispatched any more, starts the forced
|
||||
// wrap-up (exactly once: the settle sequence with the budget words) and reports
|
||||
// that the normal round must not run. ctx is the planner loop's context.
|
||||
func (e *Engine) budgetStop(ctx context.Context, t *Task) bool {
|
||||
l := e.budgetFor(t.ID)
|
||||
if l == nil {
|
||||
return false
|
||||
}
|
||||
st := l.State()
|
||||
log.Printf("[budget] task %s planner round: %s", t.ID, st.Line())
|
||||
if st.Dispatch {
|
||||
return false
|
||||
}
|
||||
if l.BeginWrapup() {
|
||||
log.Printf("[budget] task %s cannot afford another worker (estimate %d tokens / %d turns), forced wrap-up",
|
||||
t.ID, st.Estimate.Tokens, st.Estimate.Turns)
|
||||
e.settleTask(ctx, t, true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// budgetRoundCtx attaches a planner round's budget (the one-line remaining
|
||||
// budget, the wrap-up flag) and meters the round's own calls into the task.
|
||||
func (e *Engine) budgetRoundCtx(ctx context.Context, t *Task, wrapup bool) context.Context {
|
||||
l := e.budgetFor(t.ID)
|
||||
if l == nil {
|
||||
return ctx
|
||||
}
|
||||
ctx = agent.WithBudgetRound(ctx, agent.BudgetRound{Line: l.State().Line(), WrapUp: wrapup})
|
||||
return llmrec.WithUsageSink(ctx, l.Record)
|
||||
}
|
||||
|
||||
// budgetBlocksClaim reports whether a worker must not claim a new intent: the
|
||||
// remaining task budget is below one worker's cost estimate, or the wrap-up started.
|
||||
func (e *Engine) budgetBlocksClaim(taskID string) bool {
|
||||
l := e.budgetFor(taskID)
|
||||
return l != nil && !l.State().Dispatch
|
||||
}
|
||||
|
||||
// budgetIntent opens the meter of one worker run: ctx meters its calls and
|
||||
// clamps its turns; the returned meter feeds the tool hook. nil = no budget.
|
||||
func (e *Engine) budgetIntent(ctx context.Context, taskID string) (context.Context, *agent.IntentMeter) {
|
||||
l := e.budgetFor(taskID)
|
||||
if l == nil {
|
||||
return ctx, nil
|
||||
}
|
||||
m := l.StartIntent()
|
||||
return llmrec.WithUsageSink(agent.WithIntentMeter(ctx, m), m.Record), m
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
)
|
||||
|
||||
// With the budget env vars unset the engine decides exactly as before (no round
|
||||
// stopped, no claim blocked, contexts untouched, every tool call let through);
|
||||
// a task budget flips the same decisions once the task cannot afford a worker.
|
||||
func TestEngineBudgetPassthroughAndGate(t *testing.T) {
|
||||
for _, k := range []string{"ARTEX_TASK_BUDGET_TOKENS", "ARTEX_TASK_BUDGET_TURNS", "ARTEX_INTENT_BUDGET_TOKENS", "ARTEX_INTENT_BUDGET_TURNS"} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
ctx, task := context.Background(), &Task{ID: "7"}
|
||||
noSteer := func() (string, bool) { return "", false }
|
||||
e := NewEngine(nil)
|
||||
wctx, meter := e.budgetIntent(ctx, task.ID)
|
||||
if e.budgetStop(ctx, task) || e.budgetBlocksClaim(task.ID) || e.budgetRoundCtx(ctx, task, false) != ctx || wctx != ctx || meter != nil {
|
||||
t.Fatal("no budget: a decision or a context changed")
|
||||
}
|
||||
if block, _, _ := (steerHooks{drain: noSteer}).PreToolUse(ctx, "Bash", nil); block {
|
||||
t.Fatal("no budget: a tool call was refused")
|
||||
}
|
||||
|
||||
t.Setenv("ARTEX_TASK_BUDGET_TOKENS", "100000")
|
||||
e = NewEngine(nil)
|
||||
_, meter = e.budgetIntent(ctx, task.ID)
|
||||
meter.Record(llm.Usage{InputTokens: 60_000}) // remaining 40k < default worker estimate 50k
|
||||
h := steerHooks{drain: noSteer, budget: meter}
|
||||
if block, _, _ := h.PreToolUse(ctx, "Bash", nil); !e.budgetBlocksClaim(task.ID) || block {
|
||||
t.Fatal("budget: claim must be blocked, the running worker (above the reserve) not")
|
||||
}
|
||||
meter.Record(llm.Usage{OutputTokens: 20_000}) // reaches the 20k reserve
|
||||
if block, msg, _ := h.PreToolUse(ctx, "Bash", nil); !block || msg != agent.WorkerBudgetWrapup {
|
||||
t.Fatalf("budget: exhausted run not sent to wrap-up (block=%v msg=%q)", block, msg)
|
||||
}
|
||||
if block, _, _ := h.PreToolUse(ctx, "record_fact", nil); block {
|
||||
t.Fatal("budget: write-back tool refused during the wrap-up")
|
||||
}
|
||||
}
|
||||
|
||||
// A finished run that settled with the budget wrap-up must not gate its revive:
|
||||
// after resetSettledRun the task plans and claims again, with a fresh ledger.
|
||||
func TestResetSettledRunRestartsBudget(t *testing.T) {
|
||||
t.Setenv("ARTEX_TASK_BUDGET_TOKENS", "100000")
|
||||
t.Setenv("ARTEX_TASK_BUDGET_TURNS", "")
|
||||
t.Setenv("ARTEX_INTENT_BUDGET_TOKENS", "")
|
||||
t.Setenv("ARTEX_INTENT_BUDGET_TURNS", "")
|
||||
ctx, task := context.Background(), &Task{ID: "9"}
|
||||
e := NewEngine(nil)
|
||||
e.settling.Store(task.ID, true)
|
||||
l := e.budgetFor(task.ID)
|
||||
l.Record(llm.Usage{InputTokens: 90_000})
|
||||
l.BeginWrapup()
|
||||
if !e.isSettling(task.ID) || !e.budgetStop(ctx, task) || !e.budgetBlocksClaim(task.ID) {
|
||||
t.Fatal("settled run: wrap-up must gate the planner and the workers before the revive")
|
||||
}
|
||||
e.resetSettledRun(task.ID)
|
||||
if e.isSettling(task.ID) || e.budgetStop(ctx, task) || e.budgetBlocksClaim(task.ID) {
|
||||
t.Fatal("revived run: planner or workers still gated by the previous run")
|
||||
}
|
||||
if got := e.budgetFor(task.ID).State().Spent.Tokens; got != 0 {
|
||||
t.Fatalf("revived run: ledger not fresh, spent %d tokens", got)
|
||||
}
|
||||
}
|
||||
+34
-10
@@ -86,12 +86,19 @@ func (e *Engine) taskDeadline(t *Task) int64 {
|
||||
// stamps a fresh full budget. coordStarted is reset because the coordinator that
|
||||
// produced the timeout has already completed (or is in its final return path).
|
||||
func (e *Engine) resetTimeoutRevival(taskID string) {
|
||||
e.settling.Delete(taskID)
|
||||
e.resetSettledRun(taskID)
|
||||
e.deadline.Delete(taskID)
|
||||
e.stamped.Delete(taskID)
|
||||
e.coordStarted.Delete(taskID)
|
||||
}
|
||||
|
||||
// resetSettledRun clears the settle state of a finished run (settling and the
|
||||
// budget ledger) so a revived task can plan and claim again with a fresh budget.
|
||||
func (e *Engine) resetSettledRun(taskID string) {
|
||||
e.settling.Delete(taskID)
|
||||
e.budgets.Delete(taskID)
|
||||
}
|
||||
|
||||
// stampFirstRun records first_run_at + deadline_at on the FIRST real run (LLM ready)
|
||||
// of a timeout task, once per process. No-op when the task has no timeout.
|
||||
func (e *Engine) stampFirstRun(t *Task) {
|
||||
@@ -176,17 +183,23 @@ func (e *Engine) deadlineCoordinator(ctx context.Context, t *Task) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
e.settleTask(ctx, t)
|
||||
e.settleTask(ctx, t, false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// settleTask runs the ordered settle sequence once (§4 steps ①–⑥).
|
||||
func (e *Engine) settleTask(ctx context.Context, t *Task) {
|
||||
// settleTask runs the ordered settle sequence once (§4 steps ①–⑥). budget = the
|
||||
// task run budget is exhausted (budget.go) instead of the deadline: same sequence,
|
||||
// the final round carries the budget wrap-up words.
|
||||
func (e *Engine) settleTask(ctx context.Context, t *Task, budget bool) {
|
||||
if !e.markSettling(t.ID) {
|
||||
return
|
||||
}
|
||||
log.Printf("[deadline] task %s reached the timeout limit, entering the settle sequence", t.ID)
|
||||
if budget {
|
||||
log.Printf("[budget] task %s run budget exhausted, entering the settle sequence", t.ID)
|
||||
} else {
|
||||
log.Printf("[deadline] task %s reached the timeout limit, entering the settle sequence", t.ID)
|
||||
}
|
||||
|
||||
// ④ wait for running workers/planners to drain (running runs enter wrap-up on their own
|
||||
// via the clamped MaxDuration); if not drained within the grace period, hard-cancel the
|
||||
@@ -205,7 +218,7 @@ func (e *Engine) settleTask(ctx context.Context, t *Task) {
|
||||
}
|
||||
|
||||
// ⑤ one final planner round (task-timeout words, final goal judgment, no new intents).
|
||||
met := e.runFinalPlannerRound(ctx, t)
|
||||
met := e.runFinalPlannerRound(ctx, t, budget)
|
||||
if !e.beginTaskOperation(t.ID) {
|
||||
return
|
||||
}
|
||||
@@ -213,11 +226,17 @@ func (e *Engine) settleTask(ctx context.Context, t *Task) {
|
||||
|
||||
// ⑥ set the terminal state (guarded): met → done (completed); otherwise timeout. If the
|
||||
// normal path already settled done first, the guard (SetTaskStatusGuarded) rejects the
|
||||
// overwrite, preserving the completed semantics.
|
||||
// overwrite, preserving the completed semantics. A budget-exhausted run reuses the
|
||||
// timeout state (a resource limit ended it; no new task status) and is marked by an
|
||||
// activity record.
|
||||
status := "timeout"
|
||||
if met {
|
||||
status = "done"
|
||||
}
|
||||
if budget {
|
||||
e.emitActivity(t, db.Activity{Worker: "system", Kind: "text",
|
||||
Summary: "Task run budget exhausted: forced wrap-up done, task stopped (final state " + status + ")"})
|
||||
}
|
||||
won, err := e.m.SetTaskStatusGuarded(t.ID, status)
|
||||
switch {
|
||||
case err != nil:
|
||||
@@ -232,7 +251,7 @@ func (e *Engine) settleTask(ctx context.Context, t *Task) {
|
||||
// runFinalPlannerRound drives exactly ONE terminal planner round with the
|
||||
// task-timeout planner words (final goal judgment; no new intents). Waits for the
|
||||
// LLM to be ready (bounded by ctx) so a completable task isn't mis-judged timeout.
|
||||
func (e *Engine) runFinalPlannerRound(ctx context.Context, t *Task) (met bool) {
|
||||
func (e *Engine) runFinalPlannerRound(ctx context.Context, t *Task, budget bool) (met bool) {
|
||||
if e.IsDeleting(t.ID) {
|
||||
return false
|
||||
}
|
||||
@@ -251,14 +270,19 @@ func (e *Engine) runFinalPlannerRound(ctx context.Context, t *Task) (met bool) {
|
||||
}
|
||||
// Independent ctx (not wired to execCancel, so a pause/hard cancel cannot interrupt this
|
||||
// final round), with Final injecting the task-timeout words.
|
||||
fctx := e.clockCtx(ctx, t, true)
|
||||
fctx := e.clockCtx(ctx, t, !budget)
|
||||
label := "task timeout settle"
|
||||
if budget {
|
||||
fctx = e.budgetRoundCtx(fctx, t, true)
|
||||
label = "task budget settle"
|
||||
}
|
||||
if !e.beginTaskOperation(t.ID) {
|
||||
return false
|
||||
}
|
||||
defer e.decInflight(t.ID)
|
||||
emit := func(r db.Activity) { e.emitActivity(t, r) }
|
||||
e.emitActivity(t, db.Activity{Worker: "planner", Kind: "round",
|
||||
Summary: fmt.Sprintf("task timeout settle · final judgment (round %d)", e.nextPlannerRound(t.ID))})
|
||||
Summary: fmt.Sprintf("%s · final judgment (round %d)", label, e.nextPlannerRound(t.ID))})
|
||||
tTaskID, _ := strconv.ParseInt(t.ID, 10, 64)
|
||||
// The final round judges, it does not explore: a gated call never waits here.
|
||||
fctx = e.plannerGateContext(fctx, t, 0)
|
||||
|
||||
@@ -197,6 +197,10 @@ func (s *Server) admitTaskWhen(t *Task, mode string, requirePaused bool) (queued
|
||||
// otherwise the old settling flag would make every worker skip forever.
|
||||
s.engine.resetTimeoutRevival(t.ID)
|
||||
}
|
||||
if wasTerminal && status == "running" {
|
||||
// done/failed -> running: the previous run's settle state must not gate the new one.
|
||||
s.engine.resetSettledRun(t.ID)
|
||||
}
|
||||
if shouldQueue {
|
||||
if !wasQueued {
|
||||
summary := fmt.Sprintf("Queued: concurrency limit %d reached; starts automatically once a slot frees up", limit)
|
||||
|
||||
+6
-1
@@ -991,7 +991,12 @@ func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "service": "artex", "version": BuildVersion})
|
||||
out := map[string]any{"ok": true, "service": "artex", "version": BuildVersion}
|
||||
// Opt-in only (ARTEX_UPDATE_CHECK); off, this returns "" without any request.
|
||||
if latest := updates.newerVersion(r.Context(), time.Now(), BuildVersion); latest != "" {
|
||||
out["latest_version"] = latest
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
func (s *Server) stats(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -229,7 +229,7 @@ func (f *targetScopeFixture) finalPlannerRound(t *testing.T, task *Task) scopePr
|
||||
t.Helper()
|
||||
probe := newScopeProbeProvider()
|
||||
f.usePlanner(probe)
|
||||
f.e.runFinalPlannerRound(context.Background(), task)
|
||||
f.e.runFinalPlannerRound(context.Background(), task, false)
|
||||
return probe.first(t)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The update check is opt-in: only ARTEX_UPDATE_CHECK=1 (or true/on/yes) lets
|
||||
// the backend ask the public GitHub releases API for the latest ARTEX tag.
|
||||
// Unset, it makes no request at all and the UI shows no banner.
|
||||
const updateCheckURL = "https://api.github.com/repos/BaaRai/ARTEX/releases/latest"
|
||||
const updateCheckInterval = 24 * time.Hour
|
||||
|
||||
func updateCheckEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("ARTEX_UPDATE_CHECK"))) {
|
||||
case "1", "true", "on", "yes":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// updateCheck caches the last check (time + latest tag) in memory. A failed
|
||||
// check also stamps the time, so it is not retried within the same 24 h.
|
||||
type updateCheck struct {
|
||||
enabled bool
|
||||
fetch func(context.Context) (string, error)
|
||||
mu sync.Mutex
|
||||
checkedAt time.Time
|
||||
tag string
|
||||
}
|
||||
|
||||
var updates = &updateCheck{enabled: updateCheckEnabled(), fetch: fetchLatestRelease}
|
||||
|
||||
// newerVersion returns the latest release tag when it is newer than current,
|
||||
// fetching it at most once per updateCheckInterval; "" otherwise.
|
||||
func (u *updateCheck) newerVersion(ctx context.Context, now time.Time, current string) string {
|
||||
if !u.enabled {
|
||||
return ""
|
||||
}
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
if u.checkedAt.IsZero() || now.Sub(u.checkedAt) >= updateCheckInterval {
|
||||
u.checkedAt = now
|
||||
u.tag = ""
|
||||
if tag, err := u.fetch(ctx); err == nil {
|
||||
u.tag = tag
|
||||
}
|
||||
}
|
||||
if u.tag != "" && versionLess(current, u.tag) {
|
||||
return u.tag
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fetchLatestRelease sends only a generic User-Agent: no cookies, no host or
|
||||
// user identifiers.
|
||||
func fetchLatestRelease(ctx context.Context) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, updateCheckURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "artex-update-check")
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", errors.New("update check: " + resp.Status)
|
||||
}
|
||||
var body struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return body.TagName, nil // "" shows nothing: parseVersion rejects it
|
||||
}
|
||||
|
||||
// versionLess reports whether a < b, comparing the leading numeric
|
||||
// major.minor.patch of each ("v2.2.0-3-gabc" reads as 2.2.0). A version
|
||||
// without a leading number (for example "dev") never compares as older.
|
||||
func versionLess(a, b string) bool {
|
||||
pa, oka := parseVersion(a)
|
||||
pb, okb := parseVersion(b)
|
||||
if !oka || !okb {
|
||||
return false
|
||||
}
|
||||
for i := range pa {
|
||||
if pa[i] != pb[i] {
|
||||
return pa[i] < pb[i]
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseVersion(s string) ([3]int, bool) {
|
||||
var out [3]int
|
||||
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
|
||||
if i := strings.IndexAny(s, "-+"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) > 3 {
|
||||
return out, false
|
||||
}
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return out, false
|
||||
}
|
||||
out[i] = n
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func countingFetch(tag string, err error, calls *int) func(context.Context) (string, error) {
|
||||
return func(context.Context) (string, error) { *calls++; return tag, err }
|
||||
}
|
||||
|
||||
func TestUpdateCheckOffByDefault(t *testing.T) {
|
||||
t.Setenv("ARTEX_UPDATE_CHECK", "")
|
||||
if updateCheckEnabled() {
|
||||
t.Fatal("update check must be off when ARTEX_UPDATE_CHECK is unset")
|
||||
}
|
||||
calls := 0
|
||||
u := &updateCheck{enabled: false, fetch: countingFetch("v9.9.9", nil, &calls)}
|
||||
if got := u.newerVersion(context.Background(), time.Now(), "2.2.0"); got != "" || calls != 0 {
|
||||
t.Fatalf("disabled: got %q with %d fetches, want no banner and no fetch", got, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheckOncePer24h(t *testing.T) {
|
||||
t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
calls := 0
|
||||
u := &updateCheck{enabled: true, fetch: countingFetch("v2.3.0", nil, &calls)}
|
||||
for _, at := range []time.Duration{0, time.Hour, 23*time.Hour + 59*time.Minute} {
|
||||
if got := u.newerVersion(context.Background(), t0.Add(at), "2.2.0"); got != "v2.3.0" {
|
||||
t.Fatalf("at +%v: got %q, want v2.3.0", at, got)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("fetched %d times within 24h, want 1", calls)
|
||||
}
|
||||
u.newerVersion(context.Background(), t0.Add(24*time.Hour), "2.2.0")
|
||||
if calls != 2 {
|
||||
t.Fatalf("fetched %d times after 24h, want 2", calls)
|
||||
}
|
||||
if got := u.newerVersion(context.Background(), t0.Add(25*time.Hour), "2.3.0"); got != "" {
|
||||
t.Fatalf("up to date: got %q, want no banner", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheckFailureShowsNothingAndWaits(t *testing.T) {
|
||||
t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
calls := 0
|
||||
u := &updateCheck{enabled: true, fetch: countingFetch("", errors.New("rate limited"), &calls)}
|
||||
for _, at := range []time.Duration{0, time.Minute, 23 * time.Hour} {
|
||||
if got := u.newerVersion(context.Background(), t0.Add(at), "2.2.0"); got != "" {
|
||||
t.Fatalf("failed check: got %q, want no banner", got)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("failed check retried %d times within 24h, want 1 attempt", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionLess(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"2.2.0", "v2.3.0", true},
|
||||
{"v2.2.0", "v2.2.0", false},
|
||||
{"2.10.0", "v2.9.0", false},
|
||||
{"2.2.0-3-gabc123-dirty", "v2.2.1", true},
|
||||
{"dev", "v9.0.0", false},
|
||||
{"2.2.0", "not-a-tag", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := versionLess(c.a, c.b); got != c.want {
|
||||
t.Errorf("versionLess(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,13 @@ import { type ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { useCurrentUser } from "@/hooks/use-current-user";
|
||||
import { api } from "@/lib/api";
|
||||
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { AccountSwitcher } from "./sidebar/account-switcher";
|
||||
@@ -30,24 +33,58 @@ function isFullBleed(pathname: string) {
|
||||
return normalized.startsWith("/function/tasks/");
|
||||
}
|
||||
|
||||
const RELEASES_URL = "https://github.com/BaaRai/ARTEX/releases";
|
||||
const UPDATE_DISMISSED_KEY = "artex-update-dismissed";
|
||||
|
||||
export function MainContent({ children }: { children: ReactNode }) {
|
||||
const currentUser = useCurrentUser();
|
||||
const pathname = usePathname();
|
||||
const [version, setVersion] = useState("");
|
||||
// Only set when the backend has the opt-in update check on (ARTEX_UPDATE_CHECK) and found a newer release.
|
||||
const [latest, setLatest] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.health()
|
||||
.then((h) => setVersion((h.version ?? "").replace(/^v(?=\d)/, "")))
|
||||
.then((h) => {
|
||||
setVersion((h.version ?? "").replace(/^v(?=\d)/, ""));
|
||||
const l = h.latest_version ?? "";
|
||||
setLatest(l && getLocalStorageValue(UPDATE_DISMISSED_KEY) !== l ? l : "");
|
||||
})
|
||||
.catch(() => setVersion(""));
|
||||
}, []);
|
||||
|
||||
const updateBanner = latest ? (
|
||||
<div className="flex shrink-0 items-center justify-center gap-2 border-b bg-muted/50 px-4 py-1 text-xs">
|
||||
<a href={RELEASES_URL} target="_blank" rel="noreferrer" className="underline-offset-2 hover:underline">
|
||||
ARTEX {latest} is available. See the release notes.
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss update notice"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setLocalStorageValue(UPDATE_DISMISSED_KEY, latest);
|
||||
setLatest("");
|
||||
}}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (isFullBleed(pathname)) {
|
||||
return <>{children}</>;
|
||||
return (
|
||||
<>
|
||||
{updateBanner}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{updateBanner}
|
||||
<header
|
||||
className={cn(
|
||||
"flex h-12 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12",
|
||||
|
||||
+1
-1
@@ -237,7 +237,7 @@ function interceptPageQuery(page: number, size: number, filter: InterceptApprova
|
||||
|
||||
export const api = {
|
||||
// Backend application version (injected via ldflags at release, default "dev").
|
||||
health: () => get<{ ok: boolean; service: string; version: string }>("/health"),
|
||||
health: () => get<{ ok: boolean; service: string; version: string; latest_version?: string }>("/health"),
|
||||
|
||||
// ---- auth ----
|
||||
authStatus: () => get<{ initialized: boolean }>("/auth/status"),
|
||||
|
||||
Reference in New Issue
Block a user