From 1d7b86798696f285308f7fa7ba0e8cfd0560067e Mon Sep 17 00:00:00 2001 From: Artur Mukhamadiev Date: Tue, 15 Sep 2026 14:39:24 +0300 Subject: [PATCH] agent: enforce the configured call budgets instead of hardcoded 100/50 The bridge counted tool and model calls against fixed 100 and 50 caps, so CONFLUENCE_WEB_MAX_CONFLUENCE_CALLS / CONFLUENCE_WEB_MAX_MODEL_CALLS above the defaults were cut short inside the container, and the model cap failed the run outright. The backend now passes both values into the container at start, the supervisor forwards them to the bridge, and LIMITS reads them with the same 1-1000 validation as the backend, falling back to 100/50 when absent or invalid. Add a test that loads LIMITS in fresh processes with good and bad values for both budgets and the deadline. --- agent/bridge.ts | 4 ++-- agent/supervisor | 5 +++++ agent/tests/limits.test.ts | 35 +++++++++++++++++++++++++++++++ agent/types.ts | 13 ++++++++++++ backend/README.md | 4 ++-- backend/containers.py | 2 ++ deploy/confluence-web.env.example | 5 +++-- 7 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 agent/tests/limits.test.ts diff --git a/agent/bridge.ts b/agent/bridge.ts index 610f02b..18a761e 100644 --- a/agent/bridge.ts +++ b/agent/bridge.ts @@ -115,13 +115,13 @@ export class Bridge { }); } async sendToolRequest(tool: string, parameters: Record): Promise { - if (this.state !== 'RUNNING' || this.pending.size >= 4 || ++this.toolCalls > 100) throw new Error('Tool call limit'); + if (this.state !== 'RUNNING' || this.pending.size >= 4 || ++this.toolCalls > LIMITS.MAX_TOOL_CALLS) throw new Error('Tool call limit'); const payload = { tool, parameters }; if (jsonBytes(payload, LIMITS.TOOL_REQUEST_PAYLOAD_MAX_BYTES) > LIMITS.TOOL_REQUEST_PAYLOAD_MAX_BYTES) throw new Error('Tool payload limit'); return (await this.request('tool_request', payload, 'tool_response')).result; } async sendModelRequest(request: ModelRequest): Promise { - if (this.state !== 'RUNNING' || this.pending.size >= 4 || ++this.modelCalls > 50) { this.failRun('execution_failed'); throw new Error('Model call limit'); } + if (this.state !== 'RUNNING' || this.pending.size >= 4 || ++this.modelCalls > LIMITS.MAX_MODEL_CALLS) { this.failRun('execution_failed'); throw new Error('Model call limit'); } if (jsonBytes(request, LIMITS.MODEL_REQUEST_PAYLOAD_MAX_BYTES) > LIMITS.MODEL_REQUEST_PAYLOAD_MAX_BYTES) { this.failRun('model_context_exceeded'); throw new Error('Model payload limit'); } return (await this.request('model_request', request, 'model_response')).result; } diff --git a/agent/supervisor b/agent/supervisor index d19ee20..5fe1e94 100755 --- a/agent/supervisor +++ b/agent/supervisor @@ -50,6 +50,11 @@ args = ['node', str(entry)] env = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'HOME': '/home/agent', 'LANG': 'C.UTF-8', 'AGENT_SUPERVISOR_FD': str(child.fileno()), 'CONFLUENCE_WEB_MAX_DEADLINE_SECONDS': str(MAX_RUN_SECONDS)} +# Per-query call budgets, forwarded verbatim; the bridge validates them and falls back to +# the contract defaults (100 Confluence / 50 model) when absent or invalid. +for name in ('CONFLUENCE_WEB_MAX_CONFLUENCE_CALLS', 'CONFLUENCE_WEB_MAX_MODEL_CALLS'): + if name in os.environ: + env[name] = os.environ[name] if '--dev' in sys.argv: env['PATH'] = os.environ.get('PATH', env['PATH']) env['HOME'] = os.environ.get('HOME', '/tmp') diff --git a/agent/tests/limits.test.ts b/agent/tests/limits.test.ts new file mode 100644 index 0000000..39466a6 --- /dev/null +++ b/agent/tests/limits.test.ts @@ -0,0 +1,35 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; + +// LIMITS is computed at module load, so each case imports types.ts in a fresh process. +function limitsWith(env: Record): { model: number; tool: number; deadline: number } { + const script = "import('./types.ts').then(m => console.log(JSON.stringify({ model: m.LIMITS.MAX_MODEL_CALLS, tool: m.LIMITS.MAX_TOOL_CALLS, deadline: m.LIMITS.MAX_DEADLINE_MS })))"; + const res = spawnSync(process.execPath, ['--import', 'tsx', '-e', script], { + cwd: path.resolve(import.meta.dirname, '..'), env: { PATH: process.env.PATH, ...env }, encoding: 'utf8', + }); + assert.equal(res.status, 0, res.stderr); + return JSON.parse(res.stdout.trim()); +} + +describe('LIMITS from the container environment', () => { + test('defaults without environment', () => { + assert.deepEqual(limitsWith({}), { model: 50, tool: 100, deadline: 900_000 }); + }); + + test('accepts in-range integers set by the backend', () => { + const l = limitsWith({ CONFLUENCE_WEB_MAX_MODEL_CALLS: '120', CONFLUENCE_WEB_MAX_CONFLUENCE_CALLS: '1000', CONFLUENCE_WEB_MAX_DEADLINE_SECONDS: '1200' }); + assert.deepEqual(l, { model: 120, tool: 1000, deadline: 1_200_000 }); + }); + + test('falls back to defaults on out-of-range or malformed values', () => { + for (const bad of ['0', '1001', '-5', '1.5', 'many', '']) { + const l = limitsWith({ CONFLUENCE_WEB_MAX_MODEL_CALLS: bad, CONFLUENCE_WEB_MAX_CONFLUENCE_CALLS: bad }); + assert.deepEqual(l, { model: 50, tool: 100, deadline: 900_000 }, `budget ${JSON.stringify(bad)}`); + } + for (const bad of ['59', '3601', '-5', '1.5', 'many', '']) { + assert.equal(limitsWith({ CONFLUENCE_WEB_MAX_DEADLINE_SECONDS: bad }).deadline, 900_000, `deadline ${JSON.stringify(bad)}`); + } + }); +}); diff --git a/agent/types.ts b/agent/types.ts index fe44b9f..88bc986 100644 --- a/agent/types.ts +++ b/agent/types.ts @@ -19,6 +19,17 @@ function maxDeadlineMs(): number { return Number.isInteger(seconds) && seconds >= 60 && seconds <= 3600 ? seconds * 1000 : 900_000; } +/** + * Per-query call budget set by the backend at container start (mirrors its Settings value). + * Absent or invalid values fall back to the contract default so the container cannot be + * talked into an unbounded budget. + */ +function callBudget(name: string, fallback: number): number { + const raw = typeof process !== 'undefined' ? process.env[name] : undefined; + const value = raw === undefined || raw === '' ? NaN : Number(raw); + return Number.isInteger(value) && value >= 1 && value <= 1000 ? value : fallback; +} + export const LIMITS = { USER_PROMPT_MAX_BYTES: 16 * MIB, FINAL_MARKDOWN_MAX_BYTES: 128 * MIB, @@ -35,6 +46,8 @@ export const LIMITS = { ARTIFACT_MAX_FILE_BYTES: 10 * MIB, ARTIFACT_MAX_TOTAL_BYTES: 50 * MIB, MAX_DEADLINE_MS: maxDeadlineMs(), + MAX_TOOL_CALLS: callBudget('CONFLUENCE_WEB_MAX_CONFLUENCE_CALLS', 100), + MAX_MODEL_CALLS: callBudget('CONFLUENCE_WEB_MAX_MODEL_CALLS', 50), MAX_REMOTE_CONCURRENCY: 4, MAX_WARNINGS: 100, MAX_ERROR_MESSAGE_BYTES: 1024, diff --git a/backend/README.md b/backend/README.md index e2eb611..1c9efc5 100644 --- a/backend/README.md +++ b/backend/README.md @@ -48,8 +48,8 @@ backend/ | `CONFLUENCE_WEB_QUERY_TIMEOUT_SECONDS` | `180.0` | Total query execution deadline; must not exceed `CONFLUENCE_WEB_MAX_DEADLINE_SECONDS`. | | `CONFLUENCE_WEB_MAX_DEADLINE_SECONDS` | `900.0` | Protocol maximum for one query (60–3600). Passed into the agent container so the supervisor and bridge enforce the same bound. | | `CONFLUENCE_WEB_CLEANUP_TIMEOUT_SECONDS` | `10.0` | Dedicated cleanup timeout. | -| `CONFLUENCE_WEB_MAX_CONFLUENCE_CALLS` | `100` | Confluence tool calls allowed per query (1–1000); cache hits are free. The history keeps at most 100 tool entries, so budgets above 100 lose later entries. | -| `CONFLUENCE_WEB_MAX_MODEL_CALLS` | `50` | Model requests allowed per query (1–1000). | +| `CONFLUENCE_WEB_MAX_CONFLUENCE_CALLS` | `100` | Confluence tool calls allowed per query (1–1000); cache hits are free on the backend side. Passed into the agent container, whose bridge enforces the same count. The history keeps at most 100 tool entries, so budgets above 100 lose later entries. | +| `CONFLUENCE_WEB_MAX_MODEL_CALLS` | `50` | Model requests allowed per query (1–1000). Passed into the agent container, whose bridge enforces the same count. | | `CONFLUENCE_WEB_QUEUE_RESERVATION_SECONDS` | `45` | Admission queue reservation window after promotion; allowed 30-60. See [QUEUE_SPECIFICATION.md](../docs/QUEUE_SPECIFICATION.md). | | `CONFLUENCE_WEB_QUEUE_HEARTBEAT_SECONDS` | `15` | Admission queue heartbeat timeout for queued (not yet reserved) tickets; allowed 5-60. | | `CONFLUENCE_WEB_QUEUE_MAX_LENGTH` | `20` | Maximum queued tickets, excluding the reserved and running sessions; allowed 1-100. | diff --git a/backend/containers.py b/backend/containers.py index 65759f4..1c41b31 100644 --- a/backend/containers.py +++ b/backend/containers.py @@ -155,6 +155,8 @@ class DockerContainerManager(ContainerManager): "-e", "HOME=/home/agent", "-e", "LANG=C.UTF-8", "-e", f"CONFLUENCE_WEB_MAX_DEADLINE_SECONDS={int(self.settings.max_deadline_seconds)}", + "-e", f"CONFLUENCE_WEB_MAX_CONFLUENCE_CALLS={self.settings.max_confluence_calls}", + "-e", f"CONFLUENCE_WEB_MAX_MODEL_CALLS={self.settings.max_model_calls}", "--tmpfs", "/work:rw,nosuid,nodev,size=256m,uid=10001,gid=10001", "--tmpfs", "/tmp:rw,nosuid,nodev,size=64m,uid=10001,gid=10001", "--tmpfs", "/home/agent:rw,nosuid,nodev,size=32m,uid=10001,gid=10001", diff --git a/deploy/confluence-web.env.example b/deploy/confluence-web.env.example index cbf45c5..5212569 100644 --- a/deploy/confluence-web.env.example +++ b/deploy/confluence-web.env.example @@ -51,8 +51,9 @@ CONFLUENCE_WEB_QUERY_TIMEOUT_SECONDS=600 CONFLUENCE_WEB_MAX_DEADLINE_SECONDS=900 CONFLUENCE_WEB_CLEANUP_TIMEOUT_SECONDS=10 # Per-query call budgets (1-1000 each); cache hits are free. Reaching a budget returns an -# error to the agent, which then answers from what it has. The request history keeps at most -# 100 tool entries, so a Confluence budget above 100 loses later history entries. +# error to the agent, which then answers from what it has. Passed into the agent container +# so its bridge enforces the same counts. The request history keeps at most 100 tool +# entries, so a Confluence budget above 100 loses later history entries. CONFLUENCE_WEB_MAX_CONFLUENCE_CALLS=100 CONFLUENCE_WEB_MAX_MODEL_CALLS=50