Skip to main content
The Decision Pipeline is OrcBot’s safety and quality control layer. It sits between the LLM’s raw output and skill execution, applying guardrails to prevent loops, duplicates, unsafe operations, and premature task termination.

Architecture

1. Parser Layer

Purpose: Extract structured decisions from messy LLM output. Implementation: ParserLayer.ts (lines 14-539)

3-Tier Fallback Strategy

// Tier 1: Native tool calling (OpenAI/Google function_call response)
if (response.toolCalls.length > 0) {
  return normalizeNativeToolResponse(response.content, response.toolCalls);
}

// Tier 2: Clean JSON with minimal extraction
const jsonMatch = rawResponse.match(/```json\s*([\s\S]*?)```/);
if (jsonMatch) {
  return JSON.parse(jsonMatch[1]);
}

// Tier 3: Aggressive extraction with schema repair
const extracted = extractFieldsFromText(rawResponse, ['reasoning', 'tools', 'completed']);
const repaired = schemaRepair(extracted);
return repaired;

StandardResponse Schema

interface StandardResponse {
  reasoning: string;       // LLM's thought process
  tools: Tool[];          // Tool calls to execute
  completed: boolean;     // Is the task done?
  summary?: string;       // Final summary (when completed=true)
  verification?: string;  // Self-verification notes
  content?: string;       // Optional text content
}

interface Tool {
  name: string;           // Skill name (e.g., "web_search")
  metadata: Record<string, any>; // Tool arguments
}

Parser Examples

Clean JSON (Tier 2): LLM Output:
{
  "reasoning": "I'll search for weather in Paris",
  "tools": [{"name": "web_search", "metadata": {"query": "Paris weather"}}],
  "completed": false
}
Parsed Output (Tier 2):
{
  "reasoning": "I'll search for weather in Paris",
  "tools": [{"name": "web_search", "metadata": {"query": "Paris weather"}}],
  "completed": false
}
Messy Output (Tier 3): LLM Output:
Let me think about this. I need to search for the weather in Paris.

Reasoning: The user wants weather info, so I'll use web_search.

Tools:
- web_search with query="Paris weather"

Completed: false
Parsed Output:
{
  "reasoning": "The user wants weather info, so I'll use web_search.",
  "tools": [{"name": "web_search", "metadata": {"query": "Paris weather"}}],
  "completed": false
}

2. Deduplication Guard

Purpose: Prevent repeated identical tool calls within the same action. Implementation: DecisionPipeline.ts (lines 45-120)

Strategy

Tracks recently executed tools using a sliding window:
// Dedup key = toolName + serialized args
const dedupKey = `${tool.name}:${JSON.stringify(tool.metadata, sortKeys)}`;

const window = this.recentToolCalls.get(actionId) || [];
if (window.includes(dedupKey)) {
  return {
    blocked: true,
    reason: `Duplicate tool call: ${tool.name} with identical args already executed in this action`,
    code: 'DEDUP_BLOCK'
  };
}

// Add to window (max 20 entries)
window.push(dedupKey);
this.recentToolCalls.set(actionId, window.slice(-20));

Exemptions

Sequential UI components are exempt from deduplication (they’re intentionally called multiple times in sequence):
const sequentialUISkills = [
  'telegram_send_buttons',
  'telegram_send_poll',
  'telegram_edit_message',
  'telegram_react'
];

if (sequentialUISkills.includes(tool.name)) {
  // Build side-effect key instead (includes message content)
  dedupKey = `${tool.name}:${metadata.chatId}:${metadata.message.slice(0, 100)}`;
}

Example Block

[PIPELINE] Step 3: Blocking duplicate tool call
Tool: web_search
Args: {"query": "Paris weather"}
Reason: Same query already executed in step 1
Audit Code: DEDUP_BLOCK

[SYSTEM INJECTION]
Pipeline notes: Your proposed tool call (web_search) was blocked because you already
executed it in a previous step with identical arguments. You have the results in
memory — use them instead of re-searching.

3. Loop Detection

Purpose: Prevent infinite cycles of the same tool or tool sequences. Implementation: DecisionPipeline.ts (lines 121-180)

Patterns Detected

1. Same-tool loops (3+ in a row):
const lastThree = toolHistory.slice(-3);
if (lastThree.every(t => t === toolName)) {
  return { blocked: true, code: 'LOOP_SAME_TOOL' };
}
Example:
Step 1: web_search
Step 2: web_search
Step 3: web_search  ← BLOCKED
2. Alternating tool loops (A→B→A→B):
const lastFour = toolHistory.slice(-4);
if (lastFour.length === 4 && 
    lastFour[0] === lastFour[2] && 
    lastFour[1] === lastFour[3]) {
  return { blocked: true, code: 'LOOP_ALTERNATING' };
}
Example:
Step 1: web_search
Step 2: browser_navigate
Step 3: web_search
Step 4: browser_navigate  ← BLOCKED
3. Search thrashing (multiple failed searches):
const recentSearches = toolHistory.slice(-5).filter(t => 
  t === 'web_search' || t === 'browser_navigate'
);

if (recentSearches.length >= 4) {
  const failedCount = recentErrors.filter(e => 
    e.tool === 'web_search' || e.tool === 'browser_navigate'
  ).length;
  
  if (failedCount >= 2) {
    return { blocked: true, code: 'SEARCH_THRASHING' };
  }
}
Example:
Step 1: web_search → FAILED (API error)
Step 2: browser_navigate → FAILED (timeout)
Step 3: web_search → OK
Step 4: web_search  ← BLOCKED (thrashing detected)

Loop Recovery Suggestions

When a loop is detected, the pipeline injects recovery guidance:
const suggestions = {
  LOOP_SAME_TOOL: "Try a different approach or tool. If web_search isn't working, use browser_navigate or deep_reason.",
  LOOP_ALTERNATING: "You're stuck in a cycle. Step back and ask: what's the core problem? Consider using deep_reason to plan a new strategy.",
  SEARCH_THRASHING: "Multiple searches have failed. The information may not be available online. Consider: (1) inform the user, (2) use a different data source, or (3) provide a partial answer based on what you know."
};

4. Channel Policy Guard

Purpose: Enforce cross-channel isolation and autonomy delivery rules. Implementation: Agent.ts (lines 878-906)

Rules

1. Cross-channel send blocking (non-admin):
if (!isAdmin && 
    action.source === 'telegram' && 
    toolName === 'send_whatsapp' &&
    !exemptTools.includes(toolName)) {
  return {
    blocked: true,
    reason: 'Cross-channel send blocked. Action source is telegram but tool targets whatsapp.',
    code: 'CROSS_CHANNEL_BLOCK'
  };
}
Exemptions: send_email (intentionally cross-channel for “email this” requests) 2. Channel configuration check:
if (toolName === 'send_telegram' && !this.telegram) {
  return {
    blocked: true,
    reason: 'Telegram channel is not configured or disabled.',
    code: 'CHANNEL_DISABLED'
  };
}
3. Autonomy delivery policy:
const allowedChannels = config.get('autonomyAllowedChannels'); // ['telegram']
const isHeartbeat = action.payload.isHeartbeat;

if (isHeartbeat && 
    toolName === 'send_discord' && 
    !allowedChannels.includes('discord')) {
  return {
    blocked: true,
    reason: 'Autonomous sends to Discord are disabled by config (autonomyAllowedChannels).',
    code: 'AUTONOMY_POLICY_BLOCK'
  };
}

Example Block

[PIPELINE] Step 2: Blocking cross-channel send
Tool: send_whatsapp
Action Source: telegram
User: non-admin
Reason: Non-admin tasks cannot send to other channels
Audit Code: CROSS_CHANNEL_BLOCK

[SYSTEM INJECTION]
Your proposed tool (send_whatsapp) was blocked because this action originated from
Telegram and you're trying to send to WhatsApp. Use send_telegram instead to reply
to the user who made this request.

5. Safety Checks

Purpose: Prevent dangerous operations in safe mode and validate tool arguments. Implementation: DecisionPipeline.ts (lines 181-250)

Safe Mode Blocks

if (config.get('safeMode')) {
  const dangerousSkills = [
    'run_command',
    'write_file', 'create_file', 'delete_file',
    'install_npm_dependency',
    'browser_navigate', 'browser_click', 'browser_type'
  ];
  
  if (dangerousSkills.includes(toolName)) {
    return {
      blocked: true,
      reason: 'Safe mode is enabled. This tool is disabled for security.',
      code: 'SAFE_MODE_BLOCK'
    };
  }
}

Argument Validation

// Missing required arguments
if (toolName === 'web_search' && !metadata.query) {
  return {
    blocked: true,
    reason: 'Missing required argument: query',
    code: 'INVALID_ARGS'
  };
}

// Invalid argument types
if (toolName === 'send_telegram' && typeof metadata.chatId !== 'string') {
  return {
    blocked: true,
    reason: 'Invalid argument: chatId must be a string',
    code: 'INVALID_ARGS'
  };
}

Restricted Path Access

const restrictedPaths = ['node_modules', '.git'];

if ((toolName === 'read_file' || toolName === 'write_file') &&
    restrictedPaths.some(p => metadata.path.includes(p))) {
  return {
    blocked: true,
    reason: 'Access to restricted paths (node_modules, .git) is blocked',
    code: 'RESTRICTED_PATH'
  };
}

6. Termination Review

Purpose: Prevent premature task completion before delivering substantive results. Implementation: BlockReviewer.ts (lines 52-797)

Review Trigger

Runs when LLM sets completed: true:
if (decision.completed) {
  const review = await blockReviewer.review(action, memory);
  
  if (review.verdict === 'BLOCK') {
    // Inject feedback and continue loop
    memory.saveMemory({
      id: `${actionId}-step-${step}-completion-audit-blocked`,
      type: 'short',
      content: `[SYSTEM: Completion blocked] ${review.reason}`,
      metadata: { 
        actionId, 
        step, 
        auditCode: review.codes // e.g., ['ACK_ONLY', 'UNSENT_RESULTS']
      }
    });
    
    decision.completed = false; // Force continuation
  }
}

Audit Codes

CodeMeaningExampleFix
NO_SENDNo user-visible reply sent for a channel taskUser asks question, agent searches but never sends answerUse send_telegram before completing
UNSENT_RESULTSDeep tool output (search/browse/command) exists after last messageAgent runs web_search but completes without sharing resultsSend summary: “Here’s what I found: [results]“
NO_SUBSTANTIVEOnly acknowledgements sent, no actual contentAgent says “Working on it…” then completesReplace with concrete findings
ACK_ONLYOnly status updates, no deliverable output”I’m searching…” → completesSend: “Found: [actual results]“
ERROR_UNRESOLVEDTool errors without explanation or recoveryweb_search fails, agent completes silentlyExplain: “Search failed due to [reason]” or retry
GENERICFallback for uncategorized issuesReview action memories manually

Audit Logic

function auditCompletion(action, memory): ReviewResult {
  const messages = memory.getActionMemories(actionId)
    .filter(m => m.metadata?.role === 'assistant');
  
  const lastMessage = messages[messages.length - 1];
  const deepToolsUsed = memory.getActionMemories(actionId)
    .filter(m => ['web_search', 'browser_navigate', 'run_command'].includes(m.metadata?.skill));
  
  // 1. Check if any message was sent
  if (action.source !== 'autonomy' && messages.length === 0) {
    return {
      verdict: 'BLOCK',
      reason: 'No user-visible message sent for a channel task.',
      codes: ['NO_SEND']
    };
  }
  
  // 2. Check for unsent deep results
  const lastDeepTool = deepToolsUsed[deepToolsUsed.length - 1];
  if (lastDeepTool && lastDeepTool.timestamp > lastMessage?.timestamp) {
    return {
      verdict: 'BLOCK',
      reason: 'You ran a research/command tool but did not send the results to the user.',
      codes: ['UNSENT_RESULTS']
    };
  }
  
  // 3. Check message quality
  const lastContent = lastMessage?.content?.toLowerCase() || '';
  const isAckOnly = /working on|searching|looking|checking|found it|done|okay|got it/i.test(lastContent) &&
                    lastContent.length < 100;
  
  if (isAckOnly) {
    return {
      verdict: 'BLOCK',
      reason: 'Your last message was just a status update. Share the actual findings.',
      codes: ['ACK_ONLY']
    };
  }
  
  // 4. Check for unresolved errors
  const errors = memory.getActionMemories(actionId)
    .filter(m => m.content.includes('FAILED') || m.content.includes('ERROR'));
  
  if (errors.length > 0 && !lastContent.includes('error') && !lastContent.includes('failed')) {
    return {
      verdict: 'BLOCK',
      reason: 'Tool errors occurred but you did not explain them to the user.',
      codes: ['ERROR_UNRESOLVED']
    };
  }
  
  // All checks passed
  return { verdict: 'ALLOW' };
}

Example Block

User: "Find the weather in Paris"
Step 1: web_search("Paris weather") → SUCCESS (returned 10 results)
Step 2: LLM proposes {completed: true, summary: "Found weather info"}

[TERMINATION REVIEW]
Verdict: BLOCK
Reason: You ran web_search but never sent the results to the user.
Audit Codes: ['UNSENT_RESULTS']

[SYSTEM INJECTION]
[SYSTEM: Completion blocked] You ran web_search but never sent the results to the
user. The user cannot see your tool observations — they only see messages you send
them. Use send_telegram to deliver a summary of the weather before completing.

[CONTINUATION]
Step 3: LLM now sends: "Weather in Paris: 8°C, partly cloudy"
Step 4: LLM completes successfully

Prompt Helpers (PromptRouter)

Purpose: Modular, task-aware prompt assembly that activates only relevant guidance. Implementation: PromptRouter.ts (lines 45-797) + src/core/prompts/ (8 helpers)

Active Helpers

The PromptRouter analyzes the task and selectively activates helpers:
const helpers = [
  new DeliveryHelper(),      // Always active (core formatting)
  new ClarityHelper(),       // Always active (output structure)
  new ChannelHelper(),       // Active when task mentions channels
  new BrowserHelper(),       // Active when task involves web/browser
  new ResearchHelper(),      // Active for research/search tasks
  new CodeHelper(),          // Active for coding/debugging tasks
  new FileHelper(),          // Active for file operations
  new TerminationHelper()    // Always active (completion guidance)
];

const activeHelpers = await router.route(taskDescription, metadata);
// Example: "Search for news" → [Delivery, Clarity, Research, Termination]

Token Savings

By only including relevant helpers, the router saves ~2,000-4,000 tokens per step:
Simple task ("ping"): 3 helpers → ~1,500 tokens
Complex task ("Build a web scraper"): 8 helpers → ~4,500 tokens

Vs. naive approach (all 8 helpers every time): ~4,500 tokens
Savings: ~3,000 tokens for simple tasks (60% reduction)

Helper Example: ResearchHelper

class ResearchHelper implements PromptHelper {
  id = 'research';
  
  async shouldActivate(context: PromptHelperContext): Promise<boolean> {
    const keywords = ['search', 'find', 'research', 'look up', 'discover'];
    return keywords.some(k => context.taskDescription.toLowerCase().includes(k));
  }
  
  async getInstructions(context: PromptHelperContext): string {
    return `
## Research Protocol

1. Start with web_search for broad coverage
2. Use browser_navigate for deep dives on specific URLs
3. Extract structured data with extract_article
4. Synthesize findings before delivering
5. Cite sources when possible

Avoid:
- Searching the same query multiple times
- Browsing without reading the search results first
- Completing without delivering substantive findings
`;
  }
}

Autopilot Mode

Purpose: Suppress clarification requests for fully autonomous operation. Configuration:
autopilotNoQuestions: true
Effect:
if (config.get('autopilotNoQuestions')) {
  // Block request_supporting_data skill
  if (toolName === 'request_supporting_data') {
    return {
      blocked: true,
      reason: 'Autopilot mode enabled — make best-effort decisions without asking',
      code: 'AUTOPILOT_BLOCK'
    };
  }
  
  // Inject instruction
  systemPrompt += `\n\nAUTOPILOT MODE: Do not ask clarifying questions. Make reasonable assumptions and proceed.`;
}

Skill Routing Rules

Purpose: Intent-based skill selection for better tool matching. Configuration:
skillRoutingRules:
  - intent: search
    preferSkills: [web_search, browser_navigate]
  - intent: code
    preferSkills: [run_command, read_file, write_file]
  - intent: communication
    preferSkills: [send_telegram, send_whatsapp, send_discord]
Effect: When the task matches an intent, the preferred skills are highlighted in the prompt:
AVAILABLE SKILLS:
★ web_search(query) — Search the web [PREFERRED for this task]
★ browser_navigate(url) — Visit a URL [PREFERRED for this task]
  http_fetch(url) — Lightweight HTTP GET/POST
  ... (40+ more skills)

Information Boundaries

Purpose: Prevent cross-user information leakage in multi-tenant deployments. Rules:
// Non-admin tasks don't see elevated context
if (!isAdmin) {
  skipJournal = true;     // JOURNAL.md is admin-only
  skipLearning = true;    // LEARNING.md is admin-only
  skipEpisodic = true;    // Episodic summaries are admin-only
}

// Non-admin tasks can't execute elevated skills
const elevatedSkills = [
  'run_command', 'write_file', 'manage_config', 'install_npm_dependency'
];

if (!isAdmin && elevatedSkills.includes(toolName)) {
  return {
    blocked: true,
    reason: 'This skill requires admin privileges',
    code: 'ELEVATED_SKILL_BLOCK'
  };
}
Effect: Non-admin tasks only see their own short-term memory + thread context. No cross-user data leakage.

Performance Metrics

Pipeline overhead per step:
  • Parser: ~10-50ms
  • Deduplication: ~1-5ms
  • Loop detection: ~2-10ms
  • Channel policy: ~1-5ms
  • Safety checks: ~2-10ms
  • Termination review: ~500-2,000ms (LLM call)
  • Total: ~520-2,080ms per step
Token costs:
  • System prompt helpers: ~2,500-4,500 tokens (depends on task)
  • Feedback injections: ~100-300 tokens per block
  • Termination review prompt: ~500-1,000 tokens

Debugging Pipeline Blocks

1. Enable pipeline logs:
logLevel: debug
2. Grep for blocks:
grep "Pipeline blocked" ~/.orcbot/daemon.log
grep "AUDIT_BLOCK" ~/.orcbot/daemon.log
3. Inspect action memories:
const memories = memory.getActionMemories(actionId);
const blocks = memories.filter(m => m.content.includes('[SYSTEM: ') || m.content.includes('Pipeline notes:'));
console.log(blocks);
4. Test termination review:
const review = await blockReviewer.review(action, memory);
console.log(review);
// { verdict: 'BLOCK', reason: '...', codes: ['NO_SEND'] }

Configuration Reference

# Pipeline behavior
autopilotNoQuestions: false       # Suppress clarification requests
overrideMode: false               # Bypass safety checks (dangerous)

# Step limits
maxStepsPerAction: 15             # Max reasoning steps
maxMessagesPerAction: 10          # Max messages sent per action
actionTimeoutMs: 1800000          # 30 minutes

# Deduplication
messageDedupWindow: 300000        # 5 minutes

# Termination review
skipTerminationReview: false      # Disable review (not recommended)

# Skill routing
skillRoutingRules:
  - intent: search
    preferSkills: [web_search]

# Safety
safeMode: false                   # Disable dangerous skills
pluginAllowList: []               # Empty = all allowed
pluginDenyList: []                # Block specific plugins

# Admin permissions
adminUserIds: []                  # User IDs with elevated access

# Autonomy delivery
autonomyAllowedChannels: []       # Channels for proactive messages

Further Reading