Files
optimclaw/src/agent/CLAUDE.md
T
88f4894a18 merge: resolve conflicts for PR #800 and #822 into staging (#881)
* 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]>
2026-03-10 11:19:23 -07:00

13 KiB
Raw Blame History

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: SessionThreadTurn. 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).
  • UndoManager is per-thread, stored in SessionManager, not on Session itself. Max 20 checkpoints (oldest dropped when exceeded).
  • Group chat detection: if metadata.chat_type is group/channel/supergroup, MEMORY.md is excluded from the system prompt to prevent leaking personal context.
  • Auth mode: if a thread has pending_auth set (e.g. from tool_auth returning awaiting_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.
  • ThreadState values: Idle, Processing, AwaitingApproval, Completed, Interrupted.
  • SessionManager maps (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 injection
  • JobDelegate (src/worker/job.rs) — background scheduler jobs, planning support, completion detection
  • ContainerDelegate (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 8085% (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 8595%.
  • 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 a Worker and an mpsc channel for WorkerMessage (Start, Stop, Ping, UserMessage).
  • subtasks — lightweight ToolExec or Background tasks spawned via spawn_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:

  1. Calls ContextManager::find_stuck_jobs() to find jobs in JobState::Stuck.
  2. Attempts ctx.attempt_recovery() (transitions back to InProgress).
  3. Returns ManualRequired if repair_attempts >= max_repair_attempts.
  4. Detects broken tools via store.get_broken_tools(5) (threshold: 5 failures). Requires with_store() to be called; returns empty without a store.
  5. Attempts to rebuild broken tools via SoftwareBuilder. Requires with_builder() to be called; returns ManualRequired without 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/Thread happen under Arc<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 SafetyLayer before returning to LLM (sanitizer → validator → policy → leak detector).
  • SessionManager uses 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_llm in AgentDeps is used for heartbeat and other lightweight tasks. Falls back to main llm if None. Use agent.cheap_llm() accessor, not deps.cheap_llm directly.
  • 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.
  • BeforeInbound and BeforeOutbound hooks 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:

  1. Add a variant to Submission enum in submission.rs
  2. Add parsing in SubmissionParser::parse()
  3. Handle in agent_loop.rs where SubmissionResult is matched (the match submission { ... } block in handle_message)
  4. Implement the handler method (usually in thread_ops.rs for session operations, or commands.rs for system commands)