* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787) GitHub Actions step-level `if:` doesn't have access to `secrets` context. Replace `if: secrets.X != ''` with `continue-on-error: true` and let the Set token step handle the fallback. Co-authored-by: Claude Sonnet 4.6 <[email protected]> * fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794) - Remove continue-on-error from staging-ci.yml app token steps (secrets are configured) - Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml already runs tests before promoting, promotion PR gets full CI on main) - Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs Co-authored-by: Claude Opus 4.6 <[email protected]> * fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802) - Remove branches:[main] filter from code_style.yml so it runs on all PRs - Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs) - Update rollup job to allow skipped clippy-windows - Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs) Co-authored-by: Claude Opus 4.6 <[email protected]> * feat: persist user_id in save_job and expose job_id on routine runs (#709) * feat: persist worker events to DB and fix activity tab rendering In-process Worker (used by Scheduler::dispatch_job) now persists events via save_job_event at key execution points: plan creation, LLM responses, tool_use, tool_result, and job completion/failure/stuck. Event data shapes match the container worker format so the gateway activity tab renders them correctly. Frontend: tool_result errors now show a red X icon with danger styling instead of a silent empty output. The result event falls back to the error field when message is absent. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: wire RoutineEngine into gateway for direct manual trigger firing Replace the message-channel hack in routines_trigger_handler with a direct call to RoutineEngine::fire_manual(), ensuring FullJob routines dispatch correctly when triggered from the web UI. Inject the engine into GatewayState from Agent::run after construction. Also persists user_id in save_job for both PG and libSQL backends, removes the source='sandbox' filter so all jobs are visible, and exposes job_id on RoutineRunInfo for the frontend job link. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: remove stale gateway_state argument from Agent::new test call sites The gateway_state parameter was removed from Agent::new during rebase (replaced by post-construction set_routine_engine_slot), but three test call sites still passed the extra None argument. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review — restore sandbox source filter, remove blank lines - Revert removal of `source = 'sandbox'` filter in all SandboxStore queries (8 sites across PG and libSQL). Sandbox-specific APIs should stay scoped to sandbox jobs; unified job listing for the Jobs tab should use a separate query path. - Remove extra blank lines in agent_loop.rs and worker.rs that caused formatting CI failure. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review — regenerate Cargo.lock, add user_id regression test - Regenerate Cargo.lock from main's lockfile to eliminate dependency version downgrades (anyhow, syn, etc.) that were churn from rebase. - Add regression test verifying user_id round-trips through save_job and get_job in the libSQL backend. Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: remove trailing blank line in libsql jobs.rs [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * test: add Postgres-side regression test for user_id persistence in save_job Mirrors the existing libSQL test (test_save_job_persists_user_id) for the Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore] since it requires a running PostgreSQL instance (integration tier). Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]> * refactor: unify three agentic loops into single AgenticLoop engine (#654) Replace three independent copy-pasted agentic loops (dispatcher, worker, container runtime) with a single shared engine in `agentic_loop.rs` that all consumers customize via the `LoopDelegate` trait. Phase 1 — Shared engine (`src/agent/agentic_loop.rs`, 205 lines): - `run_agentic_loop()` owns the core LLM → tool exec → repeat cycle - `LoopDelegate` trait (Send + Sync, &dyn dispatch) with 6 hook points - Tool intent nudge logic consolidated (was duplicated in 3 files) - Iteration limit + force-text behavior preserved Phase 2 — Three delegate implementations: - `ChatDelegate` (dispatcher.rs): 3-phase approval flow, hooks, cost guard, context compaction, skill attenuation, interruption - `JobDelegate` (worker/job.rs): planning pre-loop phase, parallel JoinSet exec, mark_completed/stuck/failed, SSE streaming, self-repair - `ContainerDelegate` (worker/container.rs): sequential tool exec, HTTP-proxied LLM, container-safe tools, credential injection Phase 3 — File moves and cleanup: - Delete `src/agent/worker.rs` — job logic moved to `src/worker/job.rs` - Rename `src/worker/runtime.rs` → `src/worker/container.rs` - Re-export `Worker`/`WorkerDeps` from `crate::worker` in `agent/mod.rs` - Update `scheduler.rs` imports to new worker location Shared helpers (`src/tools/execute.rs`): - `execute_tool_with_safety()` replaces 4 copies of validate → timeout → execute → serialize - `process_tool_result()` replaces 3 copies of sanitize → wrap → ChatMessage (also used by thread_ops.rs approval resume paths) Net result: -2,408 lines, zero duplicated loop logic, single code path for tool intent nudge and completion detection. Closes #654 Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review feedback from Copilot 1. scheduler.rs: Replace `unwrap_or` fallback with proper error propagation when parsing tool output JSON — surfaces bugs instead of silently changing the output type. 2. worker/job.rs: Drop MutexGuard before the cancellation `.await` in `check_signals()` to avoid holding a lock across an async I/O call (prevents `await_holding_lock` lint). 3. worker/job.rs: Restore consecutive rate-limit counter (MAX_CONSECUTIVE_RATE_LIMITS = 10) so sustained rate limiting marks the job stuck with "Persistent rate limiting" instead of silently burning through max_iterations. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: incorporate staging changes — token budget tracking + mark_failed Merge staging's changes into the refactored JobDelegate: - Add token budget tracking in call_llm (update_context/add_tokens) - mark_stuck → mark_failed for iteration cap and rate-limit exhaustion (aligns with staging's #788 fix) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address zmanian's PR review — eliminate type erasure, clean up Address all 6 review points from zmanian on PR #800: 1. Replace LoopOutcome::Custom(Box<dyn Any>) with typed LoopOutcome::NeedApproval(Box<PendingApproval>) — eliminates type erasure and downcast, resolves clippy large_enum_variant. 2. Remove dead max_tool_iterations field from ChatDelegate struct. 3. Add on_tool_intent_nudge() hook to LoopDelegate trait with implementations in Job and Container delegates for observability. 4. Fix SSE events in job worker to emit raw sanitized content instead of XML-wrapped <tool_output> tags. 5. Remove 4 duplicate completion tests from job.rs that were already covered by the shared util module. 6. Avoid logging full tool results — use result_size_bytes in debug logs (execute.rs, job.rs). Also updates path references in CLAUDE.md, COVERAGE_PLAN.md, and add-sse-event.md command. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(doctor): expand diagnostics from 7 to 16 health checks * test: add unit tests for agentic_loop and execute shared modules Add 16 tests covering the two new critical shared modules: agentic_loop.rs (10 tests): - Text response exits loop immediately - Tool call → text response continuation - LoopSignal::Stop exits before LLM call - LoopSignal::InjectMessage adds user message to context - Max iterations terminates with LoopOutcome::MaxIterations - Tool intent nudge fires twice then caps - before_llm_call early exit bypasses LLM - truncate_for_preview: short string, long string, multibyte safety execute.rs (6 tests): - execute_tool_with_safety success path - Missing tool returns ToolError::NotFound - Tool execution failure propagates - Per-tool timeout enforcement (50ms) - process_tool_result XML wrapping on success - process_tool_result error formatting All 2,777 unit tests pass, 0 clippy warnings. Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: cargo fmt Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address code review — 9 issues across agentic loop, job worker, container CRITICAL fixes: - Rate-limit exhaustion now returns Err(LlmError::RateLimited) instead of Ok(Text("")), stopping the loop immediately with no ghost iteration. Below-threshold retries still use Text("") with an explicit empty-string guard in handle_text_response to skip injection. - check_signals drains the entire message channel before returning, prioritizing Stop over UserMessage. Previously returned early on first UserMessage, silently dropping any queued Stop or additional messages. - check_signals now detects all non-progressing job states (Cancelled, Failed, Stuck, Completed, Submitted, Accepted) instead of only Cancelled and Failed. HIGH fixes: - Error path in process_tool_result_job applies truncate_for_preview to bound error strings in SSE/DB events (was unbounded). - Document Send+Sync lifetime constraint on LoopDelegate trait. - Test mock before_llm_call refactored from double-lock to single lock acquisition, eliminating deadlock risk on refactor. MEDIUM fixes: - CompletionReport includes actual iteration count via shared Arc<Mutex<u32>> tracker (was hardcoded 0). - process_tool_result_job return type changed from Result<bool> to Result<()> — the bool was always false (dead API). - Deduplicate truncate in container.rs; now uses truncate_for_preview from agentic_loop. Verified: 0 clippy warnings, 2781 tests pass, cargo fmt clean. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Henry Park <[email protected]> Co-authored-by: Claude Sonnet 4.6 <[email protected]> Co-authored-by: Illia Polosukhin <[email protected]> Co-authored-by: Umesh Kumar Singh <[email protected]> Co-authored-by: reidliu41 <[email protected]>
13 KiB
Agent Module
Core agent logic. This is the most complex subsystem — read this before working in src/agent/.
Module Map
| File | Role |
|---|---|
agent_loop.rs |
Agent struct, AgentDeps, main run() event loop. Delegates to siblings. |
dispatcher.rs |
Agentic loop for conversational turns: LLM call → tool execution → repeat. Injects skill context. Returns Response or NeedApproval. |
thread_ops.rs |
Thread/session operations: process_user_input, undo/redo, approval, auth-mode interception, DB hydration, compaction. |
commands.rs |
System command handlers (/help, /model, /status, /skills, etc.) and job intent handlers. |
session.rs |
Data model: Session → Thread → Turn. State machines for threads and turns. |
session_manager.rs |
Lifecycle: create/lookup sessions, map external thread IDs to internal UUIDs, prune stale sessions, manage undo managers. |
router.rs |
Routes explicit /commands to MessageIntent. Natural language bypasses the router entirely. |
scheduler.rs |
Parallel job scheduling. Maintains jobs map (full LLM-driven) and subtasks map (tool-exec/background). |
(moved to src/worker/job.rs) |
Per-job execution now lives in src/worker/job.rs as JobDelegate, using the shared run_agentic_loop() engine. |
agentic_loop.rs |
Shared agentic loop engine: run_agentic_loop(), LoopDelegate trait, LoopOutcome, LoopSignal, TextAction. All three execution paths (chat, job, container) delegate to this. |
compaction.rs |
Context window management: summarize old turns, write to workspace daily log, trim context. Three strategies. |
context_monitor.rs |
Detects memory pressure. Suggests CompactionStrategy based on usage level. |
self_repair.rs |
Detects stuck jobs and broken tools, attempts recovery. |
heartbeat.rs |
Proactive periodic execution. Reads HEARTBEAT.md, notifies via channel if findings. |
submission.rs |
Parses all user submissions into typed variants before routing. |
undo.rs |
Turn-based undo/redo with checkpoints. Checkpoints store message lists (max 20 by default). |
routine.rs |
Routine types: Trigger (cron/event/system_event/manual) + RoutineAction (lightweight/full_job) + RoutineGuardrails. |
routine_engine.rs |
Cron ticker and event matcher. Fires routines when triggers match. Lightweight runs inline; full_job dispatches to Scheduler. |
task.rs |
Task types for the scheduler: Job, ToolExec, Background. Used by spawn_subtask and spawn_batch. |
cost_guard.rs |
LLM spend and action-rate enforcement. Tracks daily budget (cents) and hourly call rate. Lives in AgentDeps. |
job_monitor.rs |
Subscribes to SSE broadcast and injects Claude Code (container) output back into the agent loop as IncomingMessage. |
Session / Thread / Turn Model
Session (per user)
└── Thread (per conversation — can have many)
└── Turn (per request/response pair)
├── user_input: String
├── response: Option<String>
├── tool_calls: Vec<ToolCall>
└── state: TurnState (Pending | Running | Complete | Failed)
- A session has one active thread at a time; threads can be switched.
- Turns are append-only. Undo rolls back by restoring a prior checkpoint (message list, not a full thread snapshot).
UndoManageris per-thread, stored inSessionManager, not onSessionitself. Max 20 checkpoints (oldest dropped when exceeded).- Group chat detection: if
metadata.chat_typeisgroup/channel/supergroup,MEMORY.mdis excluded from the system prompt to prevent leaking personal context. - Auth mode: if a thread has
pending_authset (e.g. fromtool_authreturningawaiting_token), the next user message is intercepted before any turn creation, logging, or safety validation and sent directly to the credential store. Any control submission (undo, interrupt, etc.) cancels auth mode. ThreadStatevalues:Idle,Processing,AwaitingApproval,Completed,Interrupted.SessionManagermaps(user_id, channel, external_thread_id)→ internal UUID. Prunes idle sessions every 10 minutes (warns at 1000 sessions).
Agentic Loop (dispatcher.rs)
All three execution paths (chat, job, container) now use the shared run_agentic_loop() engine in agentic_loop.rs, each providing their own LoopDelegate implementation:
ChatDelegate(dispatcher.rs) — conversational turns, tool approval, skill context injectionJobDelegate(src/worker/job.rs) — background scheduler jobs, planning support, completion detectionContainerDelegate(src/worker/container.rs) — Docker container worker, sequential tool exec, HTTP event streaming
run_agentic_loop(delegate, reasoning, reason_ctx, config)
1. Check signals (stop/cancel) via delegate.check_signals()
2. Pre-LLM hook via delegate.before_llm_call()
3. LLM call via delegate.call_llm()
4. If text response → delegate.handle_text_response() → Continue or Return
5. If tool calls → delegate.execute_tool_calls() → Continue or Return
6. Post-iteration hook via delegate.after_iteration()
7. Repeat until LoopOutcome returned or max_iterations reached
Tool approval: Tools flagged requires_approval pause the loop — ChatDelegate returns LoopOutcome::NeedApproval(pending). The web gateway stores the PendingApproval in session state and sends an approval_needed SSE event. The user's approval/deny resumes the loop.
Shared tool execution: tools/execute.rs provides execute_tool_with_safety() (validate → timeout → execute → serialize) and process_tool_result() (sanitize → wrap → ChatMessage), used by all three delegates.
ChatDelegate vs JobDelegate: ChatDelegate runs for user-initiated conversational turns (holds session lock, tracks turns). JobDelegate is spawned by the Scheduler for background jobs created via CreateJob / /job — it runs independently of the session and has planning support (use_planning flag).
Command Routing (router.rs)
The Router handles explicit /commands (prefix /). It parses them into MessageIntent variants: CreateJob, CheckJobStatus, CancelJob, ListJobs, HelpJob, Command. Natural language messages bypass the router entirely — they go directly to dispatcher.rs via process_user_input. Note: most user-facing commands (undo, compact, etc.) are handled by SubmissionParser before the router runs, so Router only sees unrecognized /xxx patterns that haven't already been claimed by submission.rs.
Compaction
Triggered by ContextMonitor when token usage approaches the model's context limit.
Token estimation: Word-count × 1.3 + 4 overhead per message. Default context limit: 100,000 tokens. Compaction threshold: 80% (configurable).
Three strategies, chosen by ContextMonitor.suggest_compaction() based on usage ratio:
- MoveToWorkspace — Writes full turn transcript to workspace daily log, keeps 10 recent turns. Used when usage is 80–85% (moderate). Falls back to
Truncate(5)if no workspace. - Summarize (
keep_recent: N) — LLM generates a summary of old turns, writes it to workspace daily log (daily/YYYY-MM-DD.md), removes old turns. Used when usage is 85–95%. - Truncate (
keep_recent: N) — Removes oldest turns without summarization (fast path). Used when usage >95% (critical).
If the LLM call for summarization fails, the error propagates — turns are not truncated on failure.
Manual trigger: user sends /compact (parsed by submission.rs).
Scheduler
Scheduler maintains two maps under Arc<RwLock<HashMap>>:
jobs— full LLM-driven jobs, each with aWorkerand anmpscchannel forWorkerMessage(Start,Stop,Ping,UserMessage).subtasks— lightweightToolExecorBackgroundtasks spawned viaspawn_subtask()/spawn_batch().
Preferred entry point: dispatch_job() — creates context, optionally sets metadata, persists to DB (so FK references from job_actions/llm_calls are valid immediately), then calls schedule(). Don't call schedule() directly unless you've already persisted.
Check-insert is done under a single write lock to prevent TOCTOU races. A cleanup task polls every second for job completion and removes the entry from the map.
spawn_subtask() returns a oneshot::Receiver — callers must await it to get the result. spawn_batch() runs all tasks concurrently and returns results in input order.
Self-Repair
DefaultSelfRepair runs on repair_check_interval (from AgentConfig). It:
- Calls
ContextManager::find_stuck_jobs()to find jobs inJobState::Stuck. - Attempts
ctx.attempt_recovery()(transitions back toInProgress). - Returns
ManualRequiredifrepair_attempts >= max_repair_attempts. - Detects broken tools via
store.get_broken_tools(5)(threshold: 5 failures). Requireswith_store()to be called; returns empty without a store. - Attempts to rebuild broken tools via
SoftwareBuilder. Requireswith_builder()to be called; returnsManualRequiredwithout a builder.
Note: the stuck_threshold duration is stored but currently unused (marked #[allow(dead_code)]). Stuck detection relies on JobState::Stuck being set by the state machine, not wall-clock time comparison.
Repair results: Success, Retry, Failed, ManualRequired. Retry does NOT notify the user (to avoid spam).
Key Invariants
- Never call
.unwrap()or.expect()— use?with proper error mapping. - All state mutations on
Session/Threadhappen underArc<Mutex<Session>>lock. - The agent loop is single-threaded per thread; parallel execution happens at the job/scheduler level.
- Skills are selected deterministically (no LLM call) — see
skills/selector.rs. - Tool results pass through
SafetyLayerbefore returning to LLM (sanitizer → validator → policy → leak detector). SessionManageruses double-checked locking for session creation. Read lock first (fast path), then write lock with re-check to prevent duplicate sessions.Scheduler.schedule()holds the write lock for the entire check-insert sequence — don't hold any other locks when calling it.cheap_llminAgentDepsis used for heartbeat and other lightweight tasks. Falls back to mainllmifNone. Useagent.cheap_llm()accessor, notdeps.cheap_llmdirectly.CostGuard.check_allowed()must be called before LLM calls;record_llm_call()must be called after. Both calls are separate — the guard does not auto-record.BeforeInboundandBeforeOutboundhooks run for every user message and agent response respectively. Hooks can modify content or reject. Hook errors are logged but fail-open (processing continues).
Complete Submission Command Reference
All commands parsed by SubmissionParser::parse():
| Input | Variant | Notes |
|---|---|---|
/undo |
Undo |
|
/redo |
Redo |
|
/interrupt, /stop |
Interrupt |
|
/compact |
Compact |
|
/clear |
Clear |
|
/heartbeat |
Heartbeat |
|
/summarize, /summary |
Summarize |
|
/suggest |
Suggest |
|
/new, /thread new |
NewThread |
|
/thread <uuid> |
SwitchThread |
Must be valid UUID |
/resume <uuid> |
Resume |
Must be valid UUID |
/status [id], /progress [id], /list |
JobStatus |
/list = all jobs |
/cancel <id> |
JobCancel |
|
/quit, /exit, /shutdown |
Quit |
|
yes/y/approve/ok and aliases |
ApprovalResponse { approved: true, always: false } |
|
always/a and aliases |
ApprovalResponse { approved: true, always: true } |
|
no/n/deny/reject/cancel and aliases |
ApprovalResponse { approved: false } |
|
JSON ExecApproval{...} |
ExecApproval |
From web gateway approval endpoint |
/help, /? |
SystemCommand { "help" } |
Bypasses thread-state checks |
/version |
SystemCommand { "version" } |
|
/tools |
SystemCommand { "tools" } |
|
/skills [search <q>] |
SystemCommand { "skills" } |
|
/ping |
SystemCommand { "ping" } |
|
/debug |
SystemCommand { "debug" } |
|
/model [name] |
SystemCommand { "model" } |
|
| Everything else | UserInput |
Starts a new agentic turn |
SystemCommand vs control: SystemCommand variants bypass thread-state checks entirely (no session lock, no turn creation). Quit returns Ok(None) from handle_message which breaks the main loop.
Adding a New Submission Command
Submissions are special messages parsed in submission.rs before the agentic loop runs. To add a new one:
- Add a variant to
Submissionenum insubmission.rs - Add parsing in
SubmissionParser::parse() - Handle in
agent_loop.rswhereSubmissionResultis matched (thematch submission { ... }block inhandle_message) - Implement the handler method (usually in
thread_ops.rsfor session operations, orcommands.rsfor system commands)