mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
Budgets: revive clears the settle state, document config, allow update_finding_report in wrap-up
- resetSettledRun clears settling and the budget ledger; a done/failed task revived to running now plans and claims again (was gated by the old wrap-up). resetTimeoutRevival calls it. Test: TestResetSettledRunRestartsBudget. - docker-compose.yml passes the four ARTEX_*_BUDGET_* variables through. - .env.example and README document the variables, the wrap-up, the in-memory ledger and the practical floor. - llmrec: the usage sink runs even without a PostgreSQL recorder; comment fixed. - BudgetWrapupBlocks allows update_finding_report.
This commit is contained in:
@@ -33,6 +33,17 @@ ARTEX_LLM_PROXY=
|
||||
# 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 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.
|
||||
|
||||
@@ -266,6 +266,8 @@ Optional: `ARTEX_LLM_PROVIDER` (anthropic | openai | openai-responses | ollama)
|
||||
|
||||
**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 and worker run logs a `[budget]` line. Practical floor: below 50000 tokens a task gets no worker; from 50000 to about 70000 it 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)
|
||||
|
||||
+1
-1
@@ -212,7 +212,7 @@ func (m *IntentMeter) Finish() {
|
||||
// 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"
|
||||
return tool != "record_fact" && tool != "insert_assets" && tool != "report_finding" && tool != "update_finding_report"
|
||||
}
|
||||
|
||||
// WorkerBudgetWrapup is returned in place of a refused tool call.
|
||||
|
||||
@@ -43,6 +43,11 @@ services:
|
||||
# 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"
|
||||
|
||||
+2
-1
@@ -274,7 +274,8 @@ 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. The ctx
|
||||
// usage sink (if any) sees exactly the calls that get a row.
|
||||
// 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 {
|
||||
|
||||
@@ -42,3 +42,28 @@ func TestEngineBudgetPassthroughAndGate(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,11 +86,17 @@ 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)
|
||||
e.budgets.Delete(taskID) // a revived task also gets a fresh run budget
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user