How to Use Completion Signals to Stop Sandcastle Agent Iterations Early
Sandcastle monitors each LLM agent iteration for a configurable completion signal and terminates the loop immediately when detected, preventing wasted iterations and reducing token costs.
Sandcastle, available at mattpocock/sandcastle, runs LLM-based agents inside configurable sandbox iterations. By implementing completion signals to stop Sandcastle agent iterations early, you create deterministic termination conditions that halt execution immediately when your agent finishes its task, preventing unnecessary token consumption and keeping the sandbox clean.
How Completion Signals Work in Sandcastle
The orchestration logic resides in src/Orchestrator.ts and implements a four-stage pipeline for detecting early termination.
Default Signal Detection
If you do not specify a custom signal, Sandcastle automatically searches for the literal string "<promise>COMPLETE</promise>" after each iteration (see [Orchestrator.ts L75-L77]):
const DEFAULT_COMPLETION_SIGNAL = "<promise>COMPLETE</promise>";
Signal Normalization
The orchestrate function accepts completionSignal as either a single string or an array of strings. The orchestrator normalizes this input into an array for uniform processing (see [Orchestrator.ts L38-L45]):
if (options.completionSignal === undefined) {
completionSignals = [DEFAULT_COMPLETION_SIGNAL];
} else if (Array.isArray(options.completionSignal)) {
completionSignals = options.completionSignal;
} else {
completionSignals = [options.completionSignal];
}
Detection Logic
After each iteration completes, Sandcastle checks the agentOutput against all configured signals using substring matching (see [Orchestrator.ts L4-L7]):
const matchedSignal = completionSignals.find((sig) =>
agentOutput.includes(sig)
);
Early Exit Mechanism
When a signal matches, the orchestrator immediately returns results without processing remaining iterations (see [Orchestrator.ts L30-L38]):
if (lifecycleResult.result.completionSignal !== undefined) {
yield* display.status(
label(`Agent signaled completion after ${i} iteration(s).`),
"success",
);
return {
iterations: allIterations,
completionSignal: lifecycleResult.result.completionSignal,
stdout: allStdout,
commits: allCommits,
branch: resolvedBranch,
preservedWorktreePath: iterationPreservedPath,
};
}
Configuring Completion Signals via CLI
The CLI defined in src/cli.ts forwards the --completion-signal flag to the orchestrator. Specify this flag multiple times to create an array of valid termination signals.
Use a single custom signal:
sandcastle run \
--iterations 10 \
--completion-signal "TASK_COMPLETE" \
--prompt "Refactor the utils module"
Use multiple signals to stop when any condition is met:
sandcastle run \
--iterations 20 \
--completion-signal "BUILD_SUCCESS" \
--completion-signal "TESTED_AND_VERIFIED" \
--prompt "Build and test the application"
Programmatic API Usage
When using Sandcastle programmatically via src/run.ts, pass the completionSignal option to the orchestrate function:
import { orchestrate, DEFAULT_COMPLETION_SIGNAL } from "sandcastle";
const result = await orchestrate({
hostRepoDir: "/path/to/repo",
iterations: 15,
prompt: "Generate API documentation",
completionSignal: ["<docs-done>", DEFAULT_COMPLETION_SIGNAL],
});
console.log(`Completed after ${result.iterations.length} iterations`);
console.log(`Signal used: ${result.completionSignal}`);
The function accepts either a string or string array, providing type-safe flexibility for complex workflows.
How Agents Emit Completion Signals
Agents can trigger early termination through two output channels:
- Plain-text stdout: The agent prints the exact signal string to standard output
- Structured result events: When providers like Claude Code emit a
resultevent containing the signal string, the orchestrator extracts it fromagentOutput
Both methods converge on the same agentOutput.includes(sig) check in src/Orchestrator.ts, ensuring consistent detection regardless of transport format.
Summary
- Sandcastle checks for the literal string
"<promise>COMPLETE</promise>"by default unless you override it with thecompletionSignaloption - The
completionSignalparameter insrc/Orchestrator.tsaccepts either a single string or an array of strings to match against agent output - Detection occurs via substring matching (
agentOutput.includes(sig)) after each iteration completes - When matched, the orchestrator returns immediately from
src/Orchestrator.tswith the current iteration count, signal string, and preserved state - Configure signals via the
--completion-signalCLI flag or thecompletionSignalproperty in the programmatic API
Frequently Asked Questions
What is the default completion signal in Sandcastle?
If you do not specify a custom signal, Sandcastle searches for the literal string "<promise>COMPLETE</promise>" after each agent iteration. This constant is defined in src/Orchestrator.ts and serves as the fallback termination marker.
Can I use multiple completion signals for a single run?
Yes. Pass an array of strings to the completionSignal option programmatically, or use the --completion-signal flag multiple times in the CLI. The orchestrator checks each iteration against all provided signals and stops when any one matches.
How does Sandcastle detect completion signals in agent output?
After each iteration, Sandcastle performs a substring search using agentOutput.includes(sig) against all configured signals. This detects markers appearing in either plain stdout text or structured result events emitted by the LLM provider.
Where is the early termination logic implemented?
The early exit mechanism resides in src/Orchestrator.ts. When a signal matches, the orchestrator yields a success status and returns an OrchestrateResult object containing the completed iterations, matched signal string, and preserved worktree path, bypassing remaining iterations.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →