Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/tame-agent-inference-loops.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@roomote/api': patch
'@roomote/worker': patch
'@roomote/web': patch
'@roomote/cloud-agents': patch
'@roomote/db': patch
'@roomote/sdk': patch
'@roomote/types': patch
---

Stop repeated identical tool-call loops and bound subagent passes. Enforce a durable task-wide inference request ceiling before dispatch, retain partial work, and save a visible safety notice without requiring another model response.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 37 additions & 1 deletion apps/api/src/handlers/inference/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import {
formatSingleLineLog,
rebaseRoomoteModelIdToUpstream,
ROOMOTE_INFERENCE_PROVIDER_ID,
AGENT_SAFETY_STOP_CODE,
AGENT_SAFETY_STOP_TEXT,
} from '@roomote/types';
import {
db,
DevLoginInferencePlaceholderError,
eq,
taskRuns,
admitTaskInferenceRequest,
} from '@roomote/db/server';
import { recordLlmUsage } from '@roomote/sdk/server';

Expand Down Expand Up @@ -362,7 +365,7 @@ inference.on(['POST', 'GET'], '/:provider/*', async (c) => {
}

const taskRun = await db.query.taskRuns.findFirst({
columns: { id: true },
columns: { id: true, taskId: true },
where: eq(taskRuns.id, auth.runId),
});

Expand All @@ -383,6 +386,21 @@ inference.on(['POST', 'GET'], '/:provider/*', async (c) => {
} catch {
return c.json({ error: 'Invalid JSON' }, 400);
}
if (
!taskRun.taskId ||
!(await admitTaskInferenceRequest(taskRun.taskId, auth.runId))
) {
return c.json(
{
error: {
code: AGENT_SAFETY_STOP_CODE,
message: AGENT_SAFETY_STOP_TEXT,
type: 'invalid_request_error',
},
},
400,
);
}
return evaluateJevgrepRequest(body);
}

Expand Down Expand Up @@ -480,6 +498,24 @@ inference.on(['POST', 'GET'], '/:provider/*', async (c) => {
}

try {
// GET catalog/metadata reads are free; every POST attempt (including failed
// requests and provider retries) consumes one durable admission before fetch.
if (
method === 'POST' &&
(!taskRun.taskId ||
!(await admitTaskInferenceRequest(taskRun.taskId, auth.runId)))
) {
return c.json(
{
error: {
code: AGENT_SAFETY_STOP_CODE,
message: AGENT_SAFETY_STOP_TEXT,
type: 'invalid_request_error',
},
},
400,
);
}
const upstreamResponse = await fetchWithLongLivedStreamDispatcher(
upstreamUrl,
{
Expand Down
14 changes: 14 additions & 0 deletions apps/docs/cost-analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ you intend to share or analyze elsewhere.

## Reading the results

### Execution limits and financial caps

Tasks have [finite safety limits](/tasks#safety-stops-for-repeated-activity),
including a 1,000-request lifetime ceiling enforced before managed inference
dispatch. A provider attempt consumes an allowance even if the request fails
or reports no usage or price. Concurrent children and resumed runs share it.

This is not a hard monetary cap. Models have different request prices, provider
usage can arrive late or be missing, and requests already admitted can still
be billed. The Costs view is accounting, not pre-dispatch dollar reservation.
Configure provider-side spending limits when a fixed financial ceiling is
required. External services called by tools or user scripts can have their own
charges outside Roomote's managed inference boundary.

Cost Analytics records inference usage reported by the configured model
providers. It is separate from [Privacy](/privacy):
cost data stays inside your deployment for your team to review, while anonymous
Expand Down
32 changes: 32 additions & 0 deletions apps/docs/tasks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,38 @@ environment. Review or change those values before starting it. This creates a
new task instead of retrying the failed task, and attachments are not copied, so
reattach any files the new task needs.

## Safety stops for repeated activity

Roomote stops a task when an agent repeats an identical completed tool call
with the same arguments and result five times in succession. Empty search
results count. Changed arguments or results reset the repetition streak;
duplicate streaming updates for one invocation do not count as more calls.
The read-only task-update polling tool has a larger, finite allowance of 20
identical completions. Shell commands and search tools have no polling exception.

Subagent passes, including review, judge, explore, and custom subagents, also
stop at 100 model steps, 200 completed tools, or 20 minutes. Activity does not
extend the deadline. The main task also has repetition and completed-tool
protection. A task has a lifetime ceiling of 1,000 admitted provider requests,
shared across its models, children, retries, and resumed runs. Unknown prices
do not disable that request ceiling.

A safety stop cancels the task's agent tree instead of asking the parent agent
to retry the stopped pass. A notice is saved in the task and its owning session,
when there is one, without needing another model response. Refresh the history
if a notice has not appeared in an already-open view. Partial work is retained;
an interrupted review must not be treated as a clean review. An already-admitted
provider request can still finish and incur cost while cancellation settles.

Automatic retry, model fallback, restart, and resume do not reset a stopped
task's admission state. Inspect the partial work and the stop reason before
explicitly starting a new task with a narrower request. There is no automatic
budget reset or in-product allowance-extension control.

These are request and execution bounds, not a dollar spending limit. Use your
inference provider's own billing controls for a financial cap; see
[Cost analytics](/cost-analytics).

## Task view

The task view gives you the working context for a run:
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -1845,6 +1845,7 @@ export class AcpProtocolService {
if (
envelope.eventType === ACP_ENVELOPE_EVENT_TYPES.UserPrompt ||
envelope.eventType === ACP_ENVELOPE_EVENT_TYPES.AssistantMessage ||
envelope.eventType === ACP_ENVELOPE_EVENT_TYPES.AgentSafetyStop ||
envelope.eventType === ACP_ENVELOPE_EVENT_TYPES.AssistantThought
) {
const message = toAcpUiMessage(envelope);
Expand Down
Loading
Loading