Policy
promptChat and promptAgent are two preset variants of the same tool-call-loop policy. They are not two independent execution engines — they are convenience strategies composed from lower-level primitives.
These lower-level primitives include:
executeTurn: Execute a single model call.executeTools: Execute tool calls returned by the model.executeValidation: Parse model output (optionally with anOutputParser) and run validators registered viaexpectValidator, while reporting validation telemetry events.
All three are exported by @rejelly/core/policy. Extended policies should be composed from these primitives, rather than copying the preset implementations.
All three primitives are internal to policies: they only accept a runtime derived from the current policy's PromptContext (see Runtime Seal below). Calling them directly in an agent handler throws InvalidPromptRuntimeError. If you need to make a model call outside the preset policies, the escape hatch is to write a small custom policy instead of bypassing the policy boundary.
Basic Model
A policy is a composition layer between the agent handler and the execution primitives.
A custom policy is created with the public createAgentPolicy factory.
The policy factory handles the common prompt lifecycle:
- Reads the current agent context.
- Ensures that a prompt policy can only be called once per run.
- Freezes the draft into a base
PromptContext(containingmessages, system, instruction, tools, tool loop middleware, etc.). - Creates prompt-level telemetry spans and events.
- Calls the specific policy handler.
- Reports success or failure result metadata.
The specific policy handler then decides how to combine executeTurn, executeTools, parsing, validation, retry, and loop logic.
Tool-Call-Loop Policy
The shared preset loop is runToolCallLoopPolicy.
Its flow is:
- Read the initial
messagesfrom the basePromptContext. - Fork the history via
ctx.fork({ messages })and callexecuteTurnwith that runtime. - If the model returns content, perform parsing and validation.
- If validation fails, append error feedback to the current step's temporary conversation and retry within the same turn step.
- If the model returns tool calls, execute them via
executeToolsusing the same runtime view. - Append assistant message and tool output messages to history, then continue to the next round.
- If
maxTurnStepsis exceeded, throwToolLoopExceededError.
Two-Layer Turn Budget
maxTurnSteps operates at two layers, corresponding to two independent error types (deliberately unrelated by inheritance — the failure vocabulary belongs to each layer's perspective, and actualTurns measures different quantities):
- Engine guardrail (
TurnBudgetExceededError):executeTurnitself tracks the total model call count for this Generation (validation retries included,actualTurnsis that count) and throws whenmaxTurnStepsis reached. This is policy-agnostic — it is a safety net against infinite loops and retry storms in extended policies. For graph/ToT policies, this is the "per-Generation node budget." - Tool loop non-convergence (
ToolLoopExceededError): The preset tool-call-loop policy's failure perspective — the model kept returning tool calls overmaxTurnStepsrounds without producing final content (actualTurnsis loop rounds; validation retries within a round count as one). Suggestions to increasemaxTurnStepsapply only to this layer.
To handle "turns exhausted" uniformly, combine two type guards: isTurnBudgetExceededError(e) || isToolLoopExceededError(e).
executeTurn can be called concurrently within the same policy via Promise.all or similar; each call synchronously occupies a turn slot before the first await, so concurrent branches share the same Generation's maxTurnSteps budget — fan-out cannot bypass the engine guardrail.
Extended policies can compare PromptContext.usedTurnSteps (real-time, including validation retries) against maxTurnSteps before fan-out to assess remaining budget and degrade gracefully (e.g., reduce beam width), rather than hitting the engine guardrail mid-failure.
This way the tool loop behavior is reusable, while different public prompt APIs only need to define their own input parameter and return value shapes.
Base Runtime and Fork
Execution primitives (executeTurn / executeTools) consume a runtime snapshot: messages, system, instruction, tools, tool loop middleware, etc. — "the data needed for this model call and subsequent tool execution."
The strategy entry point receives a PromptContext which is the base runtime (plus fork(), budget, span, and other execution concerns). It is obtained by freezing the draft once at the policy entry. Afterwards, the draft should no longer be treated as a configuration source — when different nodes, branches, or tool sets are needed, derive a fork from the base runtime.
These are the phase boundaries within a single Agent run:
- Equip Phase: The agent handler calls
equipSystem,equipInstruction,equipTool,expectValidator, etc., collecting the current generation's draft. - Policy Barrier:
promptAgent,promptChat, or an extended policy is called.createAgentPolicycrosses the barrier, freezing the draft into a basePromptContext. - Policy Execution Phase: The policy consumes the base runtime. When different message histories, tool sets, or tool loop middleware are needed, derive a local runtime via
ctx.fork(...), then pass it explicitly to the execution primitives.
In early terms this can be understood as load before consume; more precisely, the handler loads the base runtime, and the policy forks the runtime during execution.
The policy execution phase follows these runtime contracts:
- Single source:
runtimeis a required parameter forexecuteTurn/executeTools— tools, tool loop middleware, and loop history are all read from it. The legacy fallback ("if no runtime is passed, fall back to the live draft") has been removed — that fallback was the root cause of primitives appearing to "work" when called directly in handlers (unfrozen draft, validators silently skipped, turn spans detached from prompt spans). - Early binding: The base
PromptContextis frozen at the policy entry. Callingequip*mid-generation does not backfill into already-forked runtimes — configure before invoking the prompt policy. - Fork shares generation-global state: Turn budget (
maxTurnSteps/ step count), journal,span, and abort signal are attached to the underlyingAgentContextand shared by reference across all forks. Therefore, concurrent fan-out branches forking different messages/tools do not split budget or lose cache (consistent with "Two-Layer Turn Budget" above).fork()only overrides runtime view fields — it never copies these global states. - offered == executed is your responsibility: If the same runtime is fed to both
executeTurnandexecuteTools, the tool set offered to the model equals what the executor actually acknowledges. Passing different tools to each causes a mismatch between "what the model sees" and "what can be executed." - messages also belong to fork: Conversation history is no longer a scattered "external parameter" in policy-local variables. Before each turn, put the current history into a fork:
const turnRuntime = ctx.fork({ messages }). Use this runtime'smessagesas the history during tool execution as well.
Runtime Seal
"Primitives can only be used during the policy execution phase" is not a documentation convention — it is enforced at runtime. The mechanism is a capability, not an ambient flag:
- The policy barrier (
createAgentPolicy), when freezing the basePromptContext, creates a seal for this policy execution, attached to the runtime as a hidden property (symbol key). The symbol key does not appear inObject.keys/ JSON serialization — the seal never leaks into traces or snapshots. - All
fork()calls share the same seal by reference — isomorphic to how turn budget, journal, and other generation-global states are shared (see rule 3 above). - When the policy handler returns or throws, the seal is invalidated in the barrier's
finallyblock — all forks are invalidated together. A runtime cannot outlive its policy.
All three primitives check the seal on entry — executeTurn / executeTools before consuming turn budget, executeValidation before opening a validation span and writing the validationRan flag. The four rejection paths correspond to the reason field of InvalidPromptRuntimeError (accompanying type guard isInvalidPromptRuntimeError, apiName indicates which primitive rejected):
reason | Scenario |
|---|---|
missing | No runtime passed (only reachable via untyped JS calls; TypeScript treats it as a compile error) |
unsealed | Hand-constructed runtime object, not derived from a policy's PromptContext |
expired | The owning policy has already returned/thrown — includes storing PromptContext in an external variable and calling it later in the handler |
foreign | Seal is still alive, but the runtime belongs to a different generation — e.g., smuggled into a sub-agent's handler via closure |
For executeValidation, runtime is purely a capability credential: it still reads validators registered via expectValidator from the draft, not from the runtime. The credential is mandatory because of bookkeeping safety — executeValidation sets validationRan, and this is the sole basis for the policy barrier's "validator contract bypassed" warning (see ⚠️ block below). If a handler could call it bare (e.g., misused as a generic validation utility once), this safety net would be silently dismantled.
An edge-case caveat: spreading a runtime ({ ...runtime, tools: [...] }) carries the same seal reference via the symbol property and will still be accepted — it is semantically a derived view of the same policy execution. However, fork() is the recommended approach — it also handles array copying and functional overrides correctly.
Being rejected by the seal does not mean your need is invalid — only that you are in the wrong place. To make model calls outside promptAgent / promptChat (pre-flight classification, routing, graph nodes…), write an inline custom policy — createAgentPolicy costs only a few lines (see skeleton below), gaining prompt spans, stream runtime, expectValidator contracts, and lifecycle preservation. Multiple independent LLM calls can be split into separate generations via reborn, or delegated to sub-agents. Manually executing a single tool (bypassing the model tool-call loop) is not restricted by this — use callTool.
PromptContext.fork() can derive these runtime fields:
const nodeRuntime = ctx.fork({
messages: [...ctx.messages, { role: "user", content: "node prompt" }],
tools: [searchTool, citeTool],
toolCallLoopMiddlewares: [],
});Model call options are not runtime fields — they belong to each executeTurn call:
const { message } = await executeTurn(nodeRuntime.messages, {
runtime: nodeRuntime,
toolChoice: "auto",
additionalOptions: { temperature: 0 },
});A minimal tool-loop policy skeleton:
import {
createAgentPolicy,
executeTools,
executeTurn,
executeValidation,
} from "@rejelly/core/policy";
import type { Message } from "@rejelly/core";
export const myPolicy = createAgentPolicy({
policyId: "my-policy",
handler: async (ctx) => {
const messages: Message[] = [...ctx.messages];
const delta: Message[] = [];
while (ctx.usedTurnSteps < ctx.maxTurnSteps) {
const runtime = ctx.fork({ messages });
const { message } = await executeTurn(runtime.messages, { runtime });
if (message.tool_calls?.length) {
messages.push(message);
delta.push(message);
const toolRuntime = ctx.fork({ messages });
const toolMessages = await executeTools(message.tool_calls, {
runtime: toolRuntime,
});
messages.push(...toolMessages);
delta.push(...toolMessages);
continue;
}
const rawText = typeof message.content === "string" ? message.content : "";
const validation = await executeValidation(rawText, { runtime });
if (!validation.success) {
throw new Error(validation.errors.join("\n"));
}
delta.push({ ...message, content: validation.data as string });
return { data: validation.data, delta };
}
throw new Error("Policy did not converge before maxTurnSteps.");
},
});Streaming call parameters do not belong to runtime:
toolChoiceis a per-turn parameter ofexecuteTurn({ toolChoice }), provider options like temperature are overridden per-turn viaexecuteTurn({ additionalOptions }). The preset tool-call-loop policy deliberately does not settoolChoiceoradditionalOptions— when such control is needed, composeexecuteTurndirectly in an extended policy.
Syntactic Sugar: executeValidatedLoopTurn
There are only three primitives (executeTurn / executeTools / executeValidation). The bookkeeping in the skeleton above — "call model once → if content returned, validate and retry with feedback within the same turn step; if tool calls returned, hand back to the outer loop" — would need to be rewritten in every tool-call-loop policy. executeValidatedLoopTurn solidifies it into an exported syntactic sugar — like equipMemo is to equipMemory: it reuses primitives, encapsulates retry counting and AttemptsExhaustedError, but is not a fourth primitive — the primitive vocabulary remains three.
import { executeValidatedLoopTurn, type LoopTurnResult } from "@rejelly/core/policy";
const result: LoopTurnResult = await executeValidatedLoopTurn({
runtime: ctx.fork({ messages }),
jsonSchema, // optional: structured output
parser, // optional: omitted means plain-text path (validators still run)
maxRetries: ctx.maxRetries,
});
if (result.kind === "content") {
// Already passed executeValidation — final result + this step's delta messages
return { data: result.data, delta: result.deltaMessages };
}
// result.kind === "tool_calls": hand result.calls to executeTools, outer loop continuesIts return type LoopTurnResult (discriminated union of content | tool_calls) is deliberately loop-shaped: the preset runToolCallLoopPolicy and evil-jelly's resilient chat policy both reuse it directly, differing only in the outer loop (how to execute tools, control tool, abort/cleanup, pendingUserInput injection, and other real product behaviors).
Both branches carry deltaMessages (all messages added this step), with different executable payloads (content gives data, tool_calls gives calls). The outer loop can therefore first push(...result.deltaMessages) universally, then branch by kind. This is critical: if a validation retry appends "failed output + feedback" and then turns into tool calls, those intra-step messages are returned via deltaMessages and are not lost (otherwise the recorded conversation history would misalign with what the model actually saw).
This also delineates the applicability boundary: when different delta shaping or retry semantics are needed, don't add parameters to this sugar — drop down to composing the three primitives yourself, just as you would switch from equipMemo to equipMemory when a custom caching strategy is needed. Sugar is convenience, not the only entry point.
Preset Variants
promptAgent(schema)
promptAgent is the structured output variant.
It:
- Directly accepts a Zod schema.
- Converts the Zod schema to JSON Schema and passes it to the model call.
- Uses a JSON output parser for parsing and validation.
- Returns only the validated structured data.
This variant is suitable when the agent handler needs a typed final result.
promptChat(options)
promptChat is the chat variant.
It:
- Accepts an additional message via
options.message. - Optionally accepts
options.schema. - Returns
{ data, delta }. deltacontains only the messages newly produced by this loop, such as assistant messages, tool output, and validation feedback.
Without a schema, data is the validated raw text. With a schema, data is the parsed structured value.
Extending Policies
Extending prompt behavior should follow the same pattern:
- Use
createAgentPolicyto access the agent context, lifecycle constraints, and telemetry. - From the received
PromptContext, usectx.fork(...)to derive each turn/node's runtime. - Compose
executeTurn,executeTools,executeValidation, and other execution primitives as needed. - Add parsing, validation, retry, or tool-loop behavior inside the policy.
- Place the public API's parameter and return value shapes at the policy boundary, rather than stuffing them into the underlying primitives.
This keeps core execution primitives simple and reusable, while letting policies define application-facing product behavior. Conversely, policies are the only place to use primitives: calling executeTurn / executeTools / executeValidation directly in a handler without going through createAgentPolicy is rejected by the Runtime Seal. Even for a one-off model call, wrap it in an inline small policy — the cost is a few lines of boilerplate, the benefit is that telemetry, validation contracts, and lifecycle have no blind spots.
⚠️ Policy final content must go through
executeValidation
expectValidator()is a public contract of the prompt phase: validators registered by an Agent should take effect for any prompt policy. Validators are stored in the draft, and the only entry point for executing them isexecuteValidation— both presetpromptAgent/promptChatcall it internally.An extended policy that parses model output directly with
OutputParser.parse(bypassingexecuteValidation) will silently skip registered validators, and no validation events will appear in traces / Review. The framework detects this when the policy returns successfully and prints a warning (validators exist in the draft butexecuteValidationwas never called this round).Correct usage: call
executeValidation(rawText, { runtime, parser, attempt })on the final model output (runtimeis required, same Runtime Seal gate asexecuteTurn). ReturnsValidationAttemptResult, and on failure thefailureis aValidationFailure(failure.type:no_content/parse_error/schema/validator) — the policy decides the feedback and retry strategy (e.g., appenderrorsas a user message and retry within the same turn step). If the policy needs to incorporate other failures (e.g., "LLM call failed") into the same attempt bookkeeping, it can extend the vocabulary (e.g.,type AttemptFailure = ValidationFailure | { type: "llm_error" }) — the failure vocabulary belongs to the policy's own perspective, and core does not provide a canonical version (AttemptsExhaustedError.lastFailureTypeis a plain string — custom vocabularies pass through this boundary directly). WhenexecuteValidationis called without aparser, it takes the plain-text path: skipping parsing, but still running validators — plain-text output is also subject toexpectValidatorconstraints.