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:
parent
a647406729
commit
1d7b867986
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
35
agent/tests/limits.test.ts
Normal file
35
agent/tests/limits.test.ts
Normal 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)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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 (60–3600). 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 (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_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_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). |
|
| `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_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. |
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user