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.
This commit is contained in:
Artur Mukhamadiev 2026-09-15 14:39:24 +03:00
parent a647406729
commit 1d7b867986
7 changed files with 62 additions and 6 deletions

View File

@ -115,13 +115,13 @@ export class Bridge {
}); });
} }
async sendToolRequest(tool: string, parameters: Record<string, any>): Promise<any> { async sendToolRequest(tool: string, parameters: Record<string, any>): Promise<any> {
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 }; 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'); 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; return (await this.request('tool_request', payload, 'tool_response')).result;
} }
async sendModelRequest(request: ModelRequest): Promise<ModelResponse> { async sendModelRequest(request: ModelRequest): Promise<ModelResponse> {
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'); } 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; return (await this.request('model_request', request, 'model_response')).result;
} }

View File

@ -50,6 +50,11 @@ args = ['node', str(entry)]
env = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'HOME': '/home/agent', 'LANG': 'C.UTF-8', env = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'HOME': '/home/agent', 'LANG': 'C.UTF-8',
'AGENT_SUPERVISOR_FD': str(child.fileno()), 'AGENT_SUPERVISOR_FD': str(child.fileno()),
'CONFLUENCE_WEB_MAX_DEADLINE_SECONDS': str(MAX_RUN_SECONDS)} '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: if '--dev' in sys.argv:
env['PATH'] = os.environ.get('PATH', env['PATH']) env['PATH'] = os.environ.get('PATH', env['PATH'])
env['HOME'] = os.environ.get('HOME', '/tmp') env['HOME'] = os.environ.get('HOME', '/tmp')

View File

@ -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<string, string>): { 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)}`);
}
});
});

View File

@ -19,6 +19,17 @@ function maxDeadlineMs(): number {
return Number.isInteger(seconds) && seconds >= 60 && seconds <= 3600 ? seconds * 1000 : 900_000; 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 = { export const LIMITS = {
USER_PROMPT_MAX_BYTES: 16 * MIB, USER_PROMPT_MAX_BYTES: 16 * MIB,
FINAL_MARKDOWN_MAX_BYTES: 128 * MIB, FINAL_MARKDOWN_MAX_BYTES: 128 * MIB,
@ -35,6 +46,8 @@ export const LIMITS = {
ARTIFACT_MAX_FILE_BYTES: 10 * MIB, ARTIFACT_MAX_FILE_BYTES: 10 * MIB,
ARTIFACT_MAX_TOTAL_BYTES: 50 * MIB, ARTIFACT_MAX_TOTAL_BYTES: 50 * MIB,
MAX_DEADLINE_MS: maxDeadlineMs(), 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_REMOTE_CONCURRENCY: 4,
MAX_WARNINGS: 100, MAX_WARNINGS: 100,
MAX_ERROR_MESSAGE_BYTES: 1024, MAX_ERROR_MESSAGE_BYTES: 1024,

View File

@ -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_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 (603600). Passed into the agent container so the supervisor and bridge enforce the same bound. | | `CONFLUENCE_WEB_MAX_DEADLINE_SECONDS` | `900.0` | Protocol maximum for one query (603600). 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_CLEANUP_TIMEOUT_SECONDS` | `10.0` | Dedicated cleanup timeout. |
| `CONFLUENCE_WEB_MAX_CONFLUENCE_CALLS` | `100` | Confluence tool calls allowed per query (11000); cache hits are free. The history keeps at most 100 tool entries, so budgets above 100 lose later entries. | | `CONFLUENCE_WEB_MAX_CONFLUENCE_CALLS` | `100` | Confluence tool calls allowed per query (11000); 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 (11000). | | `CONFLUENCE_WEB_MAX_MODEL_CALLS` | `50` | Model requests allowed per query (11000). 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_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_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. | | `CONFLUENCE_WEB_QUEUE_MAX_LENGTH` | `20` | Maximum queued tickets, excluding the reserved and running sessions; allowed 1-100. |

View File

@ -155,6 +155,8 @@ class DockerContainerManager(ContainerManager):
"-e", "HOME=/home/agent", "-e", "HOME=/home/agent",
"-e", "LANG=C.UTF-8", "-e", "LANG=C.UTF-8",
"-e", f"CONFLUENCE_WEB_MAX_DEADLINE_SECONDS={int(self.settings.max_deadline_seconds)}", "-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", "/work:rw,nosuid,nodev,size=256m,uid=10001,gid=10001",
"--tmpfs", "/tmp:rw,nosuid,nodev,size=64m,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", "--tmpfs", "/home/agent:rw,nosuid,nodev,size=32m,uid=10001,gid=10001",

View File

@ -51,8 +51,9 @@ CONFLUENCE_WEB_QUERY_TIMEOUT_SECONDS=600
CONFLUENCE_WEB_MAX_DEADLINE_SECONDS=900 CONFLUENCE_WEB_MAX_DEADLINE_SECONDS=900
CONFLUENCE_WEB_CLEANUP_TIMEOUT_SECONDS=10 CONFLUENCE_WEB_CLEANUP_TIMEOUT_SECONDS=10
# Per-query call budgets (1-1000 each); cache hits are free. Reaching a budget returns an # 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 # error to the agent, which then answers from what it has. Passed into the agent container
# 100 tool entries, so a Confluence budget above 100 loses later history entries. # 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_CONFLUENCE_CALLS=100
CONFLUENCE_WEB_MAX_MODEL_CALLS=50 CONFLUENCE_WEB_MAX_MODEL_CALLS=50