Agents - agbossjs/agboss GitHub Wiki
An @Agent runs the framework's own loop (#150, src/graph/agent-loop/): a compiled LangGraph
subgraph per agent, added to the workflow graph as one node, so describe and the flow stay
readable. There is no createAgent and no LangChain middleware โ the loop owns cost accounting,
timeouts, approval and limits. Decision: ADR-0015.
flowchart LR
I[input] --> M[model]
M -- tool calls --> B[boundary]
M -- no calls --> A[agent-replyWith]
M -- run budget spent --> E((end))
B --> AP[approval]
AP --> AP
B --> T[tool ร N]
AP --> T
B --> C[collect]
T --> C
C --> M
A --> E
| Node | Does |
|---|---|
input |
once per call of the agent: context-mode knowledge, the task, other agents' contributions, memory, decisions on its own calls |
model |
one model turn through ModelGateway.chatModel(โฆ) with the agent's tools bound; checks the run budget and modelCalls before, records usage and checks toolCalls after |
boundary |
the move joins the conversation; for each call, in order: tool judges โ agent judges beforeToolCall; a call to a tool the agent does not have is answered Tool error: there is no tool "โฆ"
|
approval |
asks about one call that waits (pause seam) and records the decision by callId
|
tool |
one task (Send) per allowed call: runs the tool with the run's signal + its timeoutMs; stores the result (or Tool error: โฆ) under its callId; reported costs join the loop's usage |
collect |
tool messages in the call order, afterToolCall (tool, then agent) for calls that ran, decisions recorded; back to model
|
agent-replyWith |
beforeAgentAnswer, then the answer leaves the loop as the agent's contribution |
the tool's guardrails beforeToolCall โ the agent's guardrails beforeToolCall โ approval โ run โ
afterToolCall guardrails (tool, then agent); beforeAgentAnswer before the answer leaves the loop,
then the agent's judges (Judges and quality gates). A person is never asked
about a move a guardrail then rejects (Guards).
- Every finished call's result is stored in the loop's state under its
callId, checkpointed node by node (durability: "sync"); each tool call is its own task, so it is stored as soon as it finishes. - Nothing finished is repeated: after a pause, a resume or a restart in a new process the loop reads stored results โ the model is not asked again for a turn it already made.
-
A call interrupted by a crash may run again (at-least-once). The tool gets the same
ctx.callIdโ side-effecting tools must use it as their idempotency key. -
callIdis the provider's tool call id; when a provider gives none, the loop assigns one unique within the run.
An exception, invalid arguments, an invalid result or an unknown tool reach the model as the call's
result (Tool error: โฆ); the loop continues.
Calls of one model turn that need no approval run concurrently; results are handed to the model in call order. Calls needing approval are asked first, one per pause, in call order.
| Limit | Default | Key when hit |
|---|---|---|
modelCalls |
12 | agents.<name>.limits.modelCalls |
toolCalls |
20 | agents.<name>.limits.toolCalls |
Counted from the flow handing the agent its work to its answer. Until @Agent({ limits }) (#152),
toolCalls comes from maxToolCalls (the agent's, then defaults.tools.maxToolCalls) when set.
Hitting one fails the run with LimitExceededError; the loop's spend so far still reaches the
ledger. agboss describe shows them: limits: modelCalls 12 (default) ยท toolCalls 8. A spent run
budget ends the loop without a model call (stopped: run budget exhausted); the next working node
then fails the run at limits.perRun.cost.
A tool declared with a channel (@Tool({ channel: TerminalUserChannel }), the channel listed in
@Workflow({ channelClasses })) waits for a decision before it runs: the run pauses
(status: "paused", ExecutionOutput.pause) and app.resume(thread, decision) continues it with a
ToolCallApprovalDecision (Pause seam). Approved โ the call runs; rejected โ the
model reads Tool error: the call was rejected by <by>[: reason].
An agent answers by script in a workflow test: replyWith(text), callTool(Tool, args) (args typed by
the tool's input DTO), or a function of the request with .thenAnswer(...) (Testing):
import { expect } from "vitest";
import { callTool, replyWith, testWith } from "agboss/testing";
const test = testWith(SupportWorkflow);
test("a refund request calls the refund tool, then answers", async ({ app, mockLlm }) => {
mockLlm(SupportAgent).thenAnswer((request) =>
request.kind === "chat" && request.messages.some((line) => line.role === "tool")
? replyWith("Refunded.")
: callTool(RefundTool, { amount: 100 }),
);
const result = await app.execute(ChatStart, { text: "refund me 100" });
expect(mockLlm(SupportAgent)).toHaveCalledTools([RefundTool]);
expect(result).toFinishWith(ChatFinish, { text: "Refunded." });
});