mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-28 00:20:16 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
322a048b39 |
@@ -1,257 +0,0 @@
|
||||
---
|
||||
description: Triage open GitHub issues — split into bugs vs features, rank by severity/opportunity, and flag under-specified issues
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash(gh issue list:*), Bash(gh issue view:*), Bash(gh api:*), Bash(git log:*), Read, Grep, Glob, Task
|
||||
argument-hint: "[--label=<filter>] [--milestone=<filter>]"
|
||||
---
|
||||
|
||||
# Issue Triage
|
||||
|
||||
You are triaging all open issues on this repository. Your job is to split them into **bugs** and **feature requests**, rank each group, assess how well-specified each issue is, and produce an actionable triage report.
|
||||
|
||||
## Step 1: Fetch all open issues
|
||||
|
||||
Fetch every open issue with metadata:
|
||||
|
||||
```
|
||||
gh issue list --state open --limit 200 --json number,title,author,labels,assignees,createdAt,updatedAt,body,commentsCount,reactionGroups,milestone
|
||||
```
|
||||
|
||||
If `$ARGUMENTS` contains `--label=<X>`, append `--label '<X>'` to the command. If it contains `--milestone=<X>`, append `--milestone '<X>'` to the command.
|
||||
|
||||
Also fetch recently closed issues (last 14 days) to detect duplicates and already-resolved work:
|
||||
|
||||
```
|
||||
gh issue list --state closed --search "closed:>=$(date -v-14d +%Y-%m-%d)" --limit 100 --json number,title,body,labels,closedAt
|
||||
```
|
||||
|
||||
**Exclude pull requests** — `gh issue list` may include PRs. Fetch open PR numbers to filter them out:
|
||||
|
||||
```
|
||||
gh pr list --state open --json number --jq '.[].number'
|
||||
```
|
||||
|
||||
Remove any issue whose number appears in this list.
|
||||
|
||||
## Step 2: Classify each issue as Bug or Feature
|
||||
|
||||
Read each issue's title, body, and labels to classify it into one of these categories:
|
||||
|
||||
### Bugs
|
||||
Issues that describe **broken existing behavior** — something that worked or should work but doesn't. Signals:
|
||||
- Labels: `bug`, `defect`, `regression`, `crash`, `error`
|
||||
- Title/body keywords: "broken", "fails", "crash", "panic", "error", "regression", "doesn't work", "unexpected behavior"
|
||||
- Includes reproduction steps or error output
|
||||
- References existing functionality not working as documented
|
||||
|
||||
### Feature Requests
|
||||
Issues that describe **new or enhanced behavior** — something that doesn't exist yet. Signals:
|
||||
- Labels: `enhancement`, `feature`, `feature-request`, `improvement`, `proposal`
|
||||
- Title/body keywords: "add", "support", "implement", "would be nice", "proposal", "RFC", "new"
|
||||
- Describes a capability the project doesn't have
|
||||
- Proposes a design or API change
|
||||
|
||||
### Ambiguous
|
||||
If an issue doesn't clearly fit either category (e.g., "improve X performance" could be a bug or a feature), classify it as **Ambiguous** and note why.
|
||||
|
||||
## Step 3: Rate issue detail level
|
||||
|
||||
For each issue, assess how well-specified it is on a 3-tier scale:
|
||||
|
||||
| Detail Level | Criteria |
|
||||
|-------------|----------|
|
||||
| **Well-specified** | Has clear description of what/why, reproduction steps (bugs) or user story (features), acceptance criteria or expected behavior, and enough context to start working immediately |
|
||||
| **Adequate** | Describes the problem or request clearly, but missing some detail — no repro steps, vague acceptance criteria, or unclear scope. Needs 1-2 clarifying questions before work can start |
|
||||
| **Under-specified** | Vague title-only or single-sentence body, no context on why it matters, no clear definition of done. Needs significant discussion before it's actionable |
|
||||
|
||||
Indicators of good specification:
|
||||
- Code snippets, error logs, or screenshots
|
||||
- Steps to reproduce (bugs)
|
||||
- Proposed API/behavior (features)
|
||||
- Links to related issues or discussions
|
||||
- Clear "done when" criteria
|
||||
|
||||
## Step 4: Rank bugs by severity
|
||||
|
||||
Score each bug on these dimensions and compute an overall severity rank:
|
||||
|
||||
### Impact (1-4)
|
||||
| Score | Level | Description |
|
||||
|-------|-------|-------------|
|
||||
| 4 | **Critical** | Data loss, security vulnerability, complete feature broken, crash in common path |
|
||||
| 3 | **High** | Major feature degraded, workaround exists but painful, affects many users |
|
||||
| 2 | **Medium** | Minor feature broken, easy workaround, affects subset of users |
|
||||
| 1 | **Low** | Cosmetic, edge case, documentation error, minor inconvenience |
|
||||
|
||||
### Urgency (1-3)
|
||||
| Score | Level | Description |
|
||||
|-------|-------|-------------|
|
||||
| 3 | **Urgent** | Security issue, regression in recent release, blocking other work |
|
||||
| 2 | **Normal** | Should be fixed in next release cycle |
|
||||
| 1 | **Low** | Fix when convenient, backlog-worthy |
|
||||
|
||||
### Scope (1-3)
|
||||
| Score | Level | Description |
|
||||
|-------|-------|-------------|
|
||||
| 3 | **Broad** | Affects core path, multiple modules, or all users |
|
||||
| 2 | **Moderate** | Affects one module or a specific configuration |
|
||||
| 1 | **Narrow** | Affects edge case or single obscure path |
|
||||
|
||||
**Bug severity score** = Impact × 2 + Urgency + Scope (base max 14)
|
||||
|
||||
Apply a one-time +2 boost if any of the following are true (max 16):
|
||||
- Has a linked PR already (someone is working on it — fast-track review)
|
||||
- Is labeled `security`
|
||||
- Is a regression (worked before, broken now)
|
||||
|
||||
## Step 5: Rank features by opportunity
|
||||
|
||||
Score each feature request on these dimensions:
|
||||
|
||||
### Value (1-4)
|
||||
| Score | Level | Description |
|
||||
|-------|-------|-------------|
|
||||
| 4 | **High** | Unlocks new use cases, frequently requested, strategic alignment |
|
||||
| 3 | **Medium-High** | Significant quality-of-life improvement, good user demand signals |
|
||||
| 2 | **Medium** | Nice to have, modest improvement to existing workflow |
|
||||
| 1 | **Low** | Marginal value, niche use case, unclear demand |
|
||||
|
||||
Look for value signals in the issue:
|
||||
- Number of thumbs-up reactions or "+1" comments
|
||||
- Multiple people asking for the same thing
|
||||
- Alignment with project roadmap (check CLAUDE.md TODOs)
|
||||
- Unblocks other features or simplifies architecture
|
||||
|
||||
### Effort estimate (1-3, inverted — lower effort = higher score)
|
||||
| Score | Level | Description |
|
||||
|-------|-------|-------------|
|
||||
| 3 | **Small** | <1 day, isolated change, clear implementation path |
|
||||
| 2 | **Medium** | 1-3 days, touches a few modules, some design needed |
|
||||
| 1 | **Large** | 3+ days, cross-cutting, needs RFC or architectural discussion |
|
||||
|
||||
### Readiness (1-3)
|
||||
| Score | Level | Description |
|
||||
|-------|-------|-------------|
|
||||
| 3 | **Ready** | Well-specified, implementation path clear, no blockers |
|
||||
| 2 | **Almost ready** | Needs minor clarification, but scope is understood |
|
||||
| 1 | **Not ready** | Needs design discussion, has open questions, blocked by other work |
|
||||
|
||||
**Opportunity score** = Value × 2 + Effort + Readiness (base max 14)
|
||||
|
||||
Apply a one-time +2 boost if any of the following are true (max 16):
|
||||
- A community member offered to implement it
|
||||
- It has a linked draft PR
|
||||
- It closes a gap listed in the project's "Current Limitations / TODOs"
|
||||
|
||||
## Step 6: Detect duplicates and relationships
|
||||
|
||||
Check for:
|
||||
- **Duplicates** — Issues describing the same bug or requesting the same feature (compare titles and bodies)
|
||||
- **Related clusters** — Groups of issues around the same area (e.g., multiple workspace issues, multiple CLI issues)
|
||||
- **Already fixed** — Open issues that may have been resolved by recently closed issues or merged PRs
|
||||
- **Blockers** — Issues that reference other issues as prerequisites ("depends on #N", "blocked by #N")
|
||||
- **Epic candidates** — Multiple small issues that could be grouped under a single tracking issue
|
||||
|
||||
## Step 7: Produce the triage report
|
||||
|
||||
Present the output in this format:
|
||||
|
||||
### Quick Stats
|
||||
|
||||
```
|
||||
Open: N | Bugs: N | Features: N | Ambiguous: N
|
||||
Well-specified: N | Adequate: N | Under-specified: N
|
||||
Unassigned: N | Stale (>30d): N
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Critical Bugs (Severity 12+)
|
||||
|
||||
Bugs that need immediate attention. For each:
|
||||
|
||||
| # | Title | Severity | Impact | Detail | Age | Assignee |
|
||||
|---|-------|----------|--------|--------|-----|----------|
|
||||
|
||||
Include a 1-line summary of the root cause if discernible from the issue.
|
||||
|
||||
### High-Priority Bugs (Severity 8-12)
|
||||
|
||||
Same table format. These should be addressed in the next release cycle.
|
||||
|
||||
### Medium/Low Bugs (Severity <8)
|
||||
|
||||
Compact table, sorted by severity descending.
|
||||
|
||||
---
|
||||
|
||||
### Quick Wins (Opportunity 12+ AND Effort = Small)
|
||||
|
||||
Features that are high-value and low-effort — do these first. For each:
|
||||
|
||||
| # | Title | Opportunity | Value | Effort | Detail | Age |
|
||||
|---|-------|-------------|-------|--------|--------|-----|
|
||||
|
||||
### High-Opportunity Features (Opportunity 10+)
|
||||
|
||||
Same table format. Worth investing in.
|
||||
|
||||
### Backlog Features (Opportunity <10)
|
||||
|
||||
Compact table, sorted by opportunity descending.
|
||||
|
||||
---
|
||||
|
||||
### Under-Specified Issues (Need Clarification)
|
||||
|
||||
Issues rated "Under-specified" that can't be triaged effectively. For each, suggest 1-2 specific questions to ask the author to make it actionable.
|
||||
|
||||
| # | Title | Type | What's missing |
|
||||
|---|-------|------|---------------|
|
||||
|
||||
### Ambiguous Issues (Bug or Feature?)
|
||||
|
||||
Issues that couldn't be clearly classified. For each, explain the ambiguity and suggest which category it likely belongs in.
|
||||
|
||||
---
|
||||
|
||||
### Duplicates & Overlaps
|
||||
|
||||
Groups of issues that appear to be duplicates or closely related. Recommend which to keep and which to close.
|
||||
|
||||
### Already Fixed?
|
||||
|
||||
Open issues that may have been resolved by recently closed issues or merged PRs.
|
||||
|
||||
### Stale Issues (>30 days, no activity)
|
||||
|
||||
Issues with no updates in 30+ days. Recommend: close, ping author, or keep.
|
||||
|
||||
---
|
||||
|
||||
### By Area
|
||||
|
||||
Group all issues by the area of the codebase they affect (infer from title/body/labels):
|
||||
|
||||
| Area | Bugs | Features | Top Priority |
|
||||
|------|------|----------|-------------|
|
||||
|
||||
### Suggested Next Actions
|
||||
|
||||
Based on the triage, provide 3-5 concrete recommendations:
|
||||
1. Which bugs to fix first and why
|
||||
2. Which quick-win features to pick up
|
||||
3. Which under-specified issues to clarify
|
||||
4. Which stale issues to close
|
||||
5. Any clusters that suggest a larger initiative
|
||||
|
||||
## Rules
|
||||
|
||||
- Use `gh` CLI for all GitHub operations. Never guess issue state — always check.
|
||||
- For large issue lists (>20), use the Task tool to parallelize fetching issue details and comments.
|
||||
- Be concise in summaries. One line per issue in tables.
|
||||
- When scoring, be honest about uncertainty. If you can't tell severity from the description, say so and rate it conservatively.
|
||||
- Factor in issue age — older unresolved bugs may indicate they're less critical than they seem, or that they're hard to fix. Note this in your assessment.
|
||||
- Check comment threads for additional context that the original body may lack. An under-specified issue with rich discussion may actually be well-understood.
|
||||
- Do NOT post comments, close issues, or take any action. This skill is read-only analysis.
|
||||
- If the repo has >100 open issues, focus the detailed analysis on the top 30 by recency and engagement (comments + reactions), and provide a summary table for the rest.
|
||||
@@ -7,39 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.5.0...ironclaw-v0.6.0) - 2026-02-19
|
||||
|
||||
### Added
|
||||
|
||||
- add issue triage skill ([#200](https://github.com/nearai/ironclaw/pull/200))
|
||||
- add PR triage dashboard skill ([#196](https://github.com/nearai/ironclaw/pull/196))
|
||||
- add OpenRouter usage examples ([#189](https://github.com/nearai/ironclaw/pull/189))
|
||||
- add Tinfoil private inference provider ([#62](https://github.com/nearai/ironclaw/pull/62))
|
||||
- shell env scrubbing and command injection detection ([#164](https://github.com/nearai/ironclaw/pull/164))
|
||||
- Add PR review tools, job monitor, and channel injection for E2E sandbox workflows ([#57](https://github.com/nearai/ironclaw/pull/57))
|
||||
- Secure prompt-based skills system (Phases 1-4) ([#51](https://github.com/nearai/ironclaw/pull/51))
|
||||
- Add benchmarking harness with spot suite ([#10](https://github.com/nearai/ironclaw/pull/10))
|
||||
- 10 infrastructure improvements from zeroclaw ([#126](https://github.com/nearai/ironclaw/pull/126))
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(rig)* prevent OpenAI Responses API panic on tool call IDs ([#182](https://github.com/nearai/ironclaw/pull/182))
|
||||
- *(docs)* correct settings storage path in README ([#194](https://github.com/nearai/ironclaw/pull/194))
|
||||
- OpenAI tool calling — schema normalization, missing types, and Responses API panic ([#132](https://github.com/nearai/ironclaw/pull/132))
|
||||
- *(security)* prevent path traversal bypass in WASM HTTP allowlist ([#137](https://github.com/nearai/ironclaw/pull/137))
|
||||
- persist OpenAI-compatible provider and respect embeddings disable ([#177](https://github.com/nearai/ironclaw/pull/177))
|
||||
- remove .expect() calls in FailoverProvider::try_providers ([#156](https://github.com/nearai/ironclaw/pull/156))
|
||||
- sentinel value collision in FailoverProvider cooldown ([#125](https://github.com/nearai/ironclaw/pull/125)) ([#154](https://github.com/nearai/ironclaw/pull/154))
|
||||
- skills module audit cleanup ([#173](https://github.com/nearai/ironclaw/pull/173))
|
||||
|
||||
### Other
|
||||
|
||||
- Fix division by zero panic in ValueEstimator::is_profitable ([#139](https://github.com/nearai/ironclaw/pull/139))
|
||||
- audit feature parity matrix against codebase and recent commits ([#202](https://github.com/nearai/ironclaw/pull/202))
|
||||
- architecture improvements for contributor velocity ([#198](https://github.com/nearai/ironclaw/pull/198))
|
||||
- fix rustfmt formatting from PR #137
|
||||
- add .env.example examples for Ollama and OpenAI-compatible ([#110](https://github.com/nearai/ironclaw/pull/110))
|
||||
|
||||
## [0.5.0](https://github.com/nearai/ironclaw/compare/v0.4.0...v0.5.0) - 2026-02-17
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+1
-1
@@ -2490,7 +2490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw"
|
||||
version = "0.6.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ exclude = [
|
||||
|
||||
[package]
|
||||
name = "ironclaw"
|
||||
version = "0.6.0"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||
|
||||
+29
-146
@@ -45,13 +45,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Tailscale integration | ✅ | ❌ | |
|
||||
| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status |
|
||||
| `doctor` diagnostics | ✅ | ❌ | |
|
||||
| Agent event broadcast | ✅ | 🚧 | SSE broadcast manager exists (SseManager) but tool/job-state events not fully wired |
|
||||
| Channel health monitor | ✅ | ❌ | Auto-restart with configurable interval |
|
||||
| Presence system | ✅ | ❌ | Beacons on connect, system presence for agents |
|
||||
| Trusted-proxy auth mode | ✅ | ❌ | Header-based auth for reverse proxies |
|
||||
| APNs push pipeline | ✅ | ❌ | Wake disconnected iOS nodes via push |
|
||||
| Oversized payload guard | ✅ | 🚧 | HTTP webhook has 64KB body limit + Content-Length check; no chat.history cap |
|
||||
| Pre-prompt context diagnostics | ✅ | ❌ | Context size logging before prompt |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -65,50 +58,23 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| HTTP webhook | ✅ | ✅ | - | axum with secret validation |
|
||||
| REPL (simple) | ✅ | ✅ | - | For testing |
|
||||
| WASM channels | ❌ | ✅ | - | IronClaw innovation |
|
||||
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web), same-phone mode with echo detection |
|
||||
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web) |
|
||||
| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username |
|
||||
| Discord | ✅ | ❌ | P2 | discord.js, thread parent binding inheritance |
|
||||
| Discord | ✅ | ❌ | P2 | discord.js |
|
||||
| Signal | ✅ | ❌ | P2 | signal-cli |
|
||||
| Slack | ✅ | ✅ | - | WASM tool |
|
||||
| iMessage | ✅ | ❌ | P3 | BlueBubbles or Linq recommended |
|
||||
| Linq | ✅ | ❌ | P3 | Real iMessage via API, no Mac required |
|
||||
| Feishu/Lark | ✅ | ❌ | P3 | Bitable create app/field tools |
|
||||
| iMessage | ✅ | ❌ | P3 | BlueBubbles recommended |
|
||||
| Feishu/Lark | ✅ | ❌ | P3 | |
|
||||
| LINE | ✅ | ❌ | P3 | |
|
||||
| WebChat | ✅ | ✅ | - | Web gateway chat |
|
||||
| Matrix | ✅ | ❌ | P3 | E2EE support |
|
||||
| Mattermost | ✅ | ❌ | P3 | Emoji reactions |
|
||||
| Mattermost | ✅ | ❌ | P3 | |
|
||||
| Google Chat | ✅ | ❌ | P3 | |
|
||||
| MS Teams | ✅ | ❌ | P3 | |
|
||||
| Twitch | ✅ | ❌ | P3 | |
|
||||
| Voice Call | ✅ | ❌ | P3 | Twilio/Telnyx, stale call reaper, pre-cached greeting |
|
||||
| Voice Call | ✅ | ❌ | P3 | Twilio/Telnyx |
|
||||
| Nostr | ✅ | ❌ | P3 | |
|
||||
|
||||
### Telegram-Specific Features (since Feb 2025)
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Forum topic creation | ✅ | ❌ | Create topics in forum groups |
|
||||
| channel_post support | ✅ | ❌ | Bot-to-bot communication |
|
||||
| User message reactions | ✅ | ❌ | Surface inbound reactions |
|
||||
| sendPoll | ✅ | ❌ | Poll creation via agent |
|
||||
| Cron/heartbeat topic targeting | ✅ | ❌ | Messages land in correct topic |
|
||||
|
||||
### Discord-Specific Features (since Feb 2025)
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Forwarded attachment downloads | ✅ | ❌ | Fetch media from forwarded messages |
|
||||
| Faster reaction state machine | ✅ | ❌ | Watchdog + debounce |
|
||||
| Thread parent binding inheritance | ✅ | ❌ | Threads inherit parent routing |
|
||||
|
||||
### Slack-Specific Features (since Feb 2025)
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Streaming draft replies | ✅ | ❌ | Partial replies via draft message updates |
|
||||
| Configurable stream modes | ✅ | ❌ | Per-channel stream behavior |
|
||||
| Thread ownership | ✅ | ❌ | Thread-level ownership tracking |
|
||||
|
||||
### Channel Features
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
@@ -121,9 +87,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Thread isolation | ✅ | ✅ | Separate sessions per thread |
|
||||
| Per-channel media limits | ✅ | 🚧 | Caption support for media; no size limits |
|
||||
| Typing indicators | ✅ | 🚧 | TUI shows status |
|
||||
| Per-channel ackReaction config | ✅ | ❌ | Customizable acknowledgement reactions |
|
||||
| Group session priming | ✅ | ❌ | Member roster injected for context |
|
||||
| Sender_id in trusted metadata | ✅ | ❌ | Exposed in system metadata |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -141,16 +104,16 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| `config` | ✅ | ✅ | - | Read/write config |
|
||||
| `channels` | ✅ | ❌ | P2 | Channel management |
|
||||
| `models` | ✅ | 🚧 | - | Model selector in TUI |
|
||||
| `status` | ✅ | ✅ | - | System status (enriched session details) |
|
||||
| `status` | ✅ | ✅ | - | System status |
|
||||
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
|
||||
| `sessions` | ✅ | ❌ | P3 | Session listing (shows subagent models) |
|
||||
| `sessions` | ✅ | ❌ | P3 | Session listing |
|
||||
| `memory` | ✅ | ✅ | - | Memory search CLI |
|
||||
| `skills` | ✅ | ✅ | - | Skills tools + web API endpoints (install, list, activate) |
|
||||
| `pairing` | ✅ | ✅ | - | list/approve, account selector |
|
||||
| `nodes` | ✅ | ❌ | P3 | Device management, remove/clear flows |
|
||||
| `skills` | ✅ | ❌ | P3 | Agent skills |
|
||||
| `pairing` | ✅ | ✅ | - | list/approve for channel DM pairing |
|
||||
| `nodes` | ✅ | ❌ | P3 | Device management |
|
||||
| `plugins` | ✅ | ❌ | P3 | Plugin management |
|
||||
| `hooks` | ✅ | ✅ | P2 | Lifecycle hooks |
|
||||
| `cron` | ✅ | ❌ | P2 | Scheduled jobs (model/thinking fields in edit) |
|
||||
| `cron` | ✅ | ❌ | P2 | Scheduled jobs |
|
||||
| `webhooks` | ✅ | ❌ | P3 | Webhook config |
|
||||
| `message send` | ✅ | ❌ | P2 | Send to channels |
|
||||
| `browser` | ✅ | ❌ | P3 | Browser automation |
|
||||
@@ -159,8 +122,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| `logs` | ✅ | ❌ | P3 | Query logs |
|
||||
| `update` | ✅ | ❌ | P3 | Self-update |
|
||||
| `completion` | ✅ | ❌ | P3 | Shell completion |
|
||||
| `/subagents spawn` | ✅ | ❌ | P3 | Spawn subagents from chat |
|
||||
| `/export-session` | ✅ | ❌ | P3 | Export current session transcript |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -177,32 +138,17 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Global sessions | ✅ | ❌ | Optional shared context |
|
||||
| Session pruning | ✅ | ❌ | Auto cleanup old sessions |
|
||||
| Context compaction | ✅ | ✅ | Auto summarization |
|
||||
| Post-compaction read audit | ✅ | ❌ | Layer 3: workspace rules appended to summaries |
|
||||
| Post-compaction context injection | ✅ | ❌ | Workspace context as system event |
|
||||
| Custom system prompts | ✅ | ✅ | Template variables, safety guardrails |
|
||||
| Skills (modular capabilities) | ✅ | ✅ | Prompt-based skills with trust gating, attenuation, activation criteria, catalog, selector |
|
||||
| Skill routing blocks | ✅ | 🚧 | ActivationCriteria (keywords, patterns, tags) but no "Use when / Don't use when" blocks |
|
||||
| Skill path compaction | ✅ | ❌ | ~ prefix to reduce prompt tokens |
|
||||
| Custom system prompts | ✅ | ✅ | Template variables |
|
||||
| Skills (modular capabilities) | ✅ | ❌ | Capability bundles |
|
||||
| Thinking modes (low/med/high) | ✅ | ❌ | Configurable reasoning depth |
|
||||
| Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model |
|
||||
| Block-level streaming | ✅ | ❌ | |
|
||||
| Tool-level streaming | ✅ | ❌ | |
|
||||
| Z.AI tool_stream | ✅ | ❌ | Real-time tool call streaming |
|
||||
| Plugin tools | ✅ | ✅ | WASM tools |
|
||||
| Tool policies (allow/deny) | ✅ | ✅ | |
|
||||
| Exec approvals (`/approve`) | ✅ | ✅ | TUI approval overlay |
|
||||
| Elevated mode | ✅ | ❌ | Privileged execution |
|
||||
| Subagent support | ✅ | ✅ | Task framework |
|
||||
| `/subagents spawn` command | ✅ | ❌ | Spawn from chat |
|
||||
| Auth profiles | ✅ | ❌ | Multiple auth strategies |
|
||||
| Generic API key rotation | ✅ | ❌ | Rotate keys across providers |
|
||||
| Stuck loop detection | ✅ | ❌ | Exponential backoff on stuck agent loops |
|
||||
| llms.txt discovery | ✅ | ❌ | Auto-discover site metadata |
|
||||
| Multiple images per tool call | ✅ | ❌ | Single tool call, multiple images |
|
||||
| URL allowlist (web_search/fetch) | ✅ | ❌ | Restrict web tool targets |
|
||||
| suppressToolErrors config | ✅ | ❌ | Hide tool errors from user |
|
||||
| Intent-first tool display | ✅ | ❌ | Details and exec summaries |
|
||||
| Transcript file size in status | ✅ | ❌ | Show size in session status |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -213,18 +159,12 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Provider | OpenClaw | IronClaw | Priority | Notes |
|
||||
|----------|----------|----------|----------|-------|
|
||||
| NEAR AI | ✅ | ✅ | - | Primary provider |
|
||||
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy; Opus 4.5, Sonnet 4, Sonnet 4.6 |
|
||||
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy |
|
||||
| OpenAI | ✅ | 🚧 | - | Via NEAR AI proxy |
|
||||
| AWS Bedrock | ✅ | ❌ | P3 | |
|
||||
| Google Gemini | ✅ | ❌ | P3 | |
|
||||
| NVIDIA API | ✅ | ❌ | P3 | New provider |
|
||||
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
|
||||
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
|
||||
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) |
|
||||
| OpenRouter | ✅ | ❌ | P3 | |
|
||||
| Ollama (local) | ✅ | ✅ | - | via `rig::providers::ollama` (full support) |
|
||||
| Perplexity | ✅ | ❌ | P3 | Freshness parameter for web_search |
|
||||
| MiniMax | ✅ | ❌ | P3 | Regional endpoint selection |
|
||||
| GLM-5 | ✅ | ❌ | P3 | |
|
||||
| node-llama-cpp | ✅ | ➖ | - | N/A for Rust |
|
||||
| llama.cpp (native) | ❌ | 🔮 | P3 | Rust bindings |
|
||||
|
||||
@@ -237,8 +177,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Cooldown management | ✅ | ✅ | Lock-free per-provider cooldown in `FailoverProvider` |
|
||||
| Per-session model override | ✅ | ✅ | Model selector in TUI |
|
||||
| Model selection UI | ✅ | ✅ | TUI keyboard shortcut |
|
||||
| Per-model thinkingDefault | ✅ | ❌ | Override thinking level per model in config |
|
||||
| 1M context beta header | ✅ | ❌ | Anthropic extended context support |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -249,8 +187,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||
|---------|----------|----------|----------|-------|
|
||||
| Image processing (Sharp) | ✅ | ❌ | P2 | Resize, format convert |
|
||||
| Configurable image resize dims | ✅ | ❌ | P2 | Per-agent dimension config |
|
||||
| Multiple images per tool call | ✅ | ❌ | P2 | Single tool invocation, multiple images |
|
||||
| Audio transcription | ✅ | ❌ | P2 | |
|
||||
| Video support | ✅ | ❌ | P3 | |
|
||||
| PDF parsing | ✅ | ❌ | P2 | pdfjs-dist |
|
||||
@@ -259,7 +195,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Vision model integration | ✅ | ❌ | P2 | Image understanding |
|
||||
| TTS (Edge TTS) | ✅ | ❌ | P3 | Text-to-speech |
|
||||
| TTS (OpenAI) | ✅ | ❌ | P3 | |
|
||||
| Incremental TTS playback | ✅ | ❌ | P3 | iOS progressive playback |
|
||||
| Sticker-to-image | ✅ | ❌ | P3 | Telegram stickers |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
@@ -282,9 +217,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Provider plugins | ✅ | ❌ | |
|
||||
| Plugin CLI (`install`, `list`) | ✅ | ✅ | `tool` subcommand |
|
||||
| ClawHub registry | ✅ | ❌ | Discovery |
|
||||
| `before_agent_start` hook | ✅ | ❌ | modelOverride/providerOverride support |
|
||||
| `before_message_write` hook | ✅ | ❌ | Pre-write message interception |
|
||||
| `llm_input`/`llm_output` hooks | ✅ | ❌ | LLM payload inspection |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -303,7 +235,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Legacy migration | ✅ | ➖ | |
|
||||
| State directory | ✅ `~/.openclaw-state/` | ✅ `~/.ironclaw/` | |
|
||||
| Credentials directory | ✅ | ✅ | Session files |
|
||||
| Full model compat fields in schema | ✅ | ❌ | pi-ai model compat exposed in config |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -316,19 +247,16 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Vector memory | ✅ | ✅ | pgvector |
|
||||
| Session-based memory | ✅ | ✅ | |
|
||||
| Hybrid search (BM25 + vector) | ✅ | ✅ | RRF algorithm |
|
||||
| Temporal decay (hybrid search) | ✅ | ❌ | Opt-in time-based scoring factor |
|
||||
| MMR re-ranking | ✅ | ❌ | Maximal marginal relevance for result diversity |
|
||||
| LLM-based query expansion | ✅ | ❌ | Expand FTS queries via LLM |
|
||||
| OpenAI embeddings | ✅ | ✅ | |
|
||||
| Gemini embeddings | ✅ | ❌ | |
|
||||
| Local embeddings | ✅ | ❌ | |
|
||||
| SQLite-vec backend | ✅ | ❌ | IronClaw uses PostgreSQL |
|
||||
| LanceDB backend | ✅ | ❌ | Configurable auto-capture max length |
|
||||
| LanceDB backend | ✅ | ❌ | |
|
||||
| QMD backend | ✅ | ❌ | |
|
||||
| Atomic reindexing | ✅ | ✅ | |
|
||||
| Embeddings batching | ✅ | ✅ | `embed_batch` on EmbeddingProvider trait |
|
||||
| Embeddings batching | ✅ | ❌ | |
|
||||
| Citation support | ✅ | ❌ | |
|
||||
| Memory CLI commands | ✅ | ✅ | `memory search/read/write/tree/status` CLI subcommands |
|
||||
| Memory CLI commands | ✅ | ❌ | `memory search/index/status` |
|
||||
| Flexible path structure | ✅ | ✅ | Filesystem-like API |
|
||||
| Identity files (AGENTS.md, etc.) | ✅ | ✅ | |
|
||||
| Daily logs | ✅ | ✅ | |
|
||||
@@ -344,16 +272,12 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|---------|----------|----------|----------|-------|
|
||||
| iOS app (SwiftUI) | ✅ | 🚫 | - | Out of scope initially |
|
||||
| Android app (Kotlin) | ✅ | 🚫 | - | Out of scope initially |
|
||||
| Apple Watch companion | ✅ | 🚫 | - | Send/receive messages MVP |
|
||||
| Gateway WebSocket client | ✅ | 🚫 | - | |
|
||||
| Camera/photo access | ✅ | 🚫 | - | |
|
||||
| Voice input | ✅ | 🚫 | - | |
|
||||
| Push-to-talk | ✅ | 🚫 | - | |
|
||||
| Location sharing | ✅ | 🚫 | - | |
|
||||
| Node pairing | ✅ | 🚫 | - | |
|
||||
| APNs push notifications | ✅ | 🚫 | - | Wake disconnected nodes before invoke |
|
||||
| Share to OpenClaw (iOS) | ✅ | 🚫 | - | iOS share sheet integration |
|
||||
| Background listening toggle | ✅ | 🚫 | - | iOS background audio |
|
||||
|
||||
### Owner: _Unassigned_ (if ever prioritized)
|
||||
|
||||
@@ -364,17 +288,12 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||
|---------|----------|----------|----------|-------|
|
||||
| SwiftUI native app | ✅ | 🚫 | - | Out of scope |
|
||||
| Menu bar presence | ✅ | 🚫 | - | Animated menubar icon |
|
||||
| Menu bar presence | ✅ | 🚫 | - | |
|
||||
| Bundled gateway | ✅ | 🚫 | - | |
|
||||
| Canvas hosting | ✅ | 🚫 | - | Agent-controlled panel with placement/resizing |
|
||||
| Voice wake | ✅ | 🚫 | - | Overlay, mic picker, language selection, live meter |
|
||||
| Voice wake overlay | ✅ | 🚫 | - | Partial transcripts, adaptive delays, dismiss animations |
|
||||
| Push-to-talk hotkey | ✅ | 🚫 | - | System-wide hotkey |
|
||||
| Canvas hosting | ✅ | 🚫 | - | |
|
||||
| Voice wake | ✅ | 🚫 | - | |
|
||||
| Exec approval dialogs | ✅ | ✅ | - | TUI overlay |
|
||||
| iMessage integration | ✅ | 🚫 | - | |
|
||||
| Instances tab | ✅ | 🚫 | - | Presence beacons across instances |
|
||||
| Agent events debug window | ✅ | 🚫 | - | Real-time event inspector |
|
||||
| Sparkle auto-updates | ✅ | 🚫 | - | Appcast distribution |
|
||||
|
||||
### Owner: _Unassigned_ (if ever prioritized)
|
||||
|
||||
@@ -391,10 +310,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Config editing | ✅ | ❌ | P3 | |
|
||||
| Debug/logs viewer | ✅ | ✅ | - | Real-time log streaming with level/target filters |
|
||||
| WebChat interface | ✅ | ✅ | - | Web gateway chat with SSE/WebSocket |
|
||||
| Canvas system (A2UI) | ✅ | ❌ | P3 | Agent-driven UI, improved asset resolution |
|
||||
| Control UI i18n | ✅ | ❌ | P3 | English, Chinese, Portuguese |
|
||||
| WebChat theme sync | ✅ | ❌ | P3 | Sync with system dark/light mode |
|
||||
| Partial output on abort | ✅ | ❌ | P2 | Preserve partial output when aborting |
|
||||
| Canvas system (A2UI) | ✅ | ❌ | P3 | Agent-driven UI |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -405,22 +321,16 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||
|---------|----------|----------|----------|-------|
|
||||
| Cron jobs | ✅ | ✅ | - | Routines with cron trigger |
|
||||
| Cron stagger controls | ✅ | ❌ | P3 | Default stagger for scheduled jobs |
|
||||
| Cron finished-run webhook | ✅ | ❌ | P3 | Webhook on job completion |
|
||||
| Timezone support | ✅ | ✅ | - | Via cron expressions |
|
||||
| One-shot/recurring jobs | ✅ | ✅ | - | Manual + cron triggers |
|
||||
| Channel health monitor | ✅ | ❌ | P2 | Auto-restart with configurable interval |
|
||||
| `beforeInbound` hook | ✅ | ✅ | P2 | |
|
||||
| `beforeOutbound` hook | ✅ | ✅ | P2 | |
|
||||
| `beforeToolCall` hook | ✅ | ✅ | P2 | |
|
||||
| `before_agent_start` hook | ✅ | ❌ | P2 | Model/provider override |
|
||||
| `before_message_write` hook | ✅ | ❌ | P2 | Pre-write interception |
|
||||
| `onMessage` hook | ✅ | ✅ | - | Routines with event trigger |
|
||||
| `onSessionStart` hook | ✅ | ✅ | P2 | |
|
||||
| `onSessionEnd` hook | ✅ | ✅ | P2 | |
|
||||
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
|
||||
| `transformResponse` hook | ✅ | ✅ | P2 | |
|
||||
| `llm_input`/`llm_output` hooks | ✅ | ❌ | P3 | LLM payload inspection |
|
||||
| Bundled hooks | ✅ | ❌ | P2 | |
|
||||
| Plugin hooks | ✅ | ❌ | P3 | |
|
||||
| Workspace hooks | ✅ | ❌ | P2 | Inline code |
|
||||
@@ -439,7 +349,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Gateway token auth | ✅ | ✅ | Bearer token auth on web gateway |
|
||||
| Device pairing | ✅ | ❌ | |
|
||||
| Tailscale identity | ✅ | ❌ | |
|
||||
| Trusted-proxy auth | ✅ | ❌ | Header-based reverse proxy auth |
|
||||
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth |
|
||||
| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
|
||||
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
||||
@@ -447,26 +356,18 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Exec approvals | ✅ | ✅ | TUI overlay |
|
||||
| TLS 1.3 minimum | ✅ | ✅ | reqwest rustls |
|
||||
| SSRF protection | ✅ | ✅ | WASM allowlist |
|
||||
| SSRF IPv6 transition bypass block | ✅ | ❌ | Block IPv4-mapped IPv6 bypasses |
|
||||
| Cron webhook SSRF guard | ✅ | ❌ | SSRF checks on webhook delivery |
|
||||
| Loopback-first | ✅ | 🚧 | HTTP binds 0.0.0.0 |
|
||||
| Docker sandbox | ✅ | ✅ | Orchestrator/worker containers |
|
||||
| Podman support | ✅ | ❌ | Alternative to Docker |
|
||||
| WASM sandbox | ❌ | ✅ | IronClaw innovation |
|
||||
| Sandbox env sanitization | ✅ | 🚧 | Shell tool scrubs env vars (secret detection); docker container env sanitization partial |
|
||||
| Tool policies | ✅ | ✅ | |
|
||||
| Elevated mode | ✅ | ❌ | |
|
||||
| Safe bins allowlist | ✅ | ❌ | Hardened path trust |
|
||||
| Safe bins allowlist | ✅ | ❌ | |
|
||||
| LD*/DYLD* validation | ✅ | ❌ | |
|
||||
| Path traversal prevention | ✅ | ✅ | Including config includes (OC-06) |
|
||||
| Credential theft via env injection | ✅ | 🚧 | Shell env scrubbing + command injection detection; no full OC-09 defense |
|
||||
| Session file permissions (0o600) | ✅ | ✅ | Session token file set to 0o600 in llm/session.rs |
|
||||
| Skill download path restriction | ✅ | ❌ | Prevent arbitrary write targets |
|
||||
| Path traversal prevention | ✅ | ✅ | |
|
||||
| Webhook signature verification | ✅ | ✅ | |
|
||||
| Media URL validation | ✅ | ❌ | |
|
||||
| Prompt injection defense | ✅ | ✅ | Pattern detection, sanitization |
|
||||
| Leak detection | ✅ | ✅ | Secret exfiltration |
|
||||
| Dangerous tool re-enable warning | ✅ | ❌ | Warn when gateway.tools.allow re-enables HTTP tools |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -486,9 +387,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Coverage | V8 | tarpaulin/llvm-cov | |
|
||||
| CI/CD | GitHub Actions | GitHub Actions | |
|
||||
| Pre-commit hooks | prek | - | Consider adding |
|
||||
| Docker: Chromium + Xvfb | ✅ | ❌ | Optional browser in container |
|
||||
| Docker: init scripts | ✅ | ❌ | /openclaw-init.d/ support |
|
||||
| Browser: extraArgs config | ✅ | ❌ | Custom Chrome launch arguments |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -501,7 +399,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
- ✅ HTTP webhook channel
|
||||
- ✅ DM pairing (ironclaw pairing list/approve, host APIs)
|
||||
- ✅ WASM tool sandbox
|
||||
- ✅ Workspace/memory with hybrid search + embeddings batching
|
||||
- ✅ Workspace/memory with hybrid search
|
||||
- ✅ Prompt injection defense
|
||||
- ✅ Heartbeat system
|
||||
- ✅ Session management
|
||||
@@ -516,12 +414,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
- ✅ Cron job scheduling (routines)
|
||||
- ✅ CLI subcommands (onboard, config, status, memory)
|
||||
- ✅ Gateway token auth
|
||||
- ✅ Skills system (prompt-based with trust gating, attenuation, activation criteria)
|
||||
- ✅ Session file permissions (0o600)
|
||||
- ✅ Memory CLI commands (search, read, write, tree, status)
|
||||
- ✅ Shell env scrubbing + command injection detection
|
||||
- ✅ Tinfoil private inference provider
|
||||
- ✅ OpenAI-compatible / OpenRouter provider support
|
||||
|
||||
### P1 - High Priority
|
||||
- ❌ Slack channel (real implementation)
|
||||
@@ -532,11 +424,9 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
### P2 - Medium Priority
|
||||
- ❌ Media handling (images, PDFs)
|
||||
- ✅ Ollama/local model support (via rig::providers::ollama)
|
||||
- ❌ Ollama/local model support
|
||||
- ❌ Configuration hot-reload
|
||||
- ❌ Webhook trigger endpoint in web gateway
|
||||
- ❌ Channel health monitor with auto-restart
|
||||
- ❌ Partial output preservation on abort
|
||||
|
||||
### P3 - Lower Priority
|
||||
- ❌ Discord channel
|
||||
@@ -545,12 +435,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
- ❌ Other messaging platforms
|
||||
- ❌ TTS/audio features
|
||||
- ❌ Video support
|
||||
- 🚧 Skills routing blocks (activation criteria exist, but no "Use when / Don't use when")
|
||||
- ❌ Skills system
|
||||
- ❌ Plugin registry
|
||||
- ❌ Streaming (block/tool/Z.AI tool_stream)
|
||||
- ❌ Memory: temporal decay, MMR re-ranking, query expansion
|
||||
- ❌ Control UI i18n
|
||||
- ❌ Stuck loop detection
|
||||
|
||||
---
|
||||
|
||||
@@ -575,12 +461,9 @@ IronClaw intentionally differs from OpenClaw in these ways:
|
||||
|
||||
1. **Rust vs TypeScript**: Native performance, memory safety, single binary distribution
|
||||
2. **WASM sandbox vs Docker**: Lighter weight, faster startup, capability-based security
|
||||
3. **PostgreSQL + libSQL vs SQLite**: Dual-backend (production PG + embedded libSQL for zero-dep local mode)
|
||||
3. **PostgreSQL vs SQLite**: Better suited for production deployments
|
||||
4. **NEAR AI focus**: Primary provider with session-based auth
|
||||
5. **No mobile/desktop apps**: Focus on server-side and CLI initially
|
||||
6. **WASM channels**: Novel extension mechanism not in OpenClaw
|
||||
7. **Tinfoil private inference**: IronClaw-only provider for private/encrypted inference
|
||||
8. **GitHub WASM tool**: Native GitHub integration as WASM tool
|
||||
9. **Prompt-based skills**: Different approach than OpenClaw capability bundles (trust gating, attenuation)
|
||||
|
||||
These are intentional architectural choices, not gaps to be filled.
|
||||
|
||||
@@ -139,9 +139,8 @@ ironclaw onboard
|
||||
```
|
||||
|
||||
The wizard handles database connection, NEAR AI authentication (via browser OAuth),
|
||||
and secrets encryption (using your system keychain). Settings are persisted in the
|
||||
connected database; bootstrap variables (e.g. `DATABASE_URL`, `LLM_BACKEND`) are
|
||||
written to `~/.ironclaw/.env` so they are available before the database connects.
|
||||
and secrets encryption (using your system keychain). All settings are saved to
|
||||
`~/.ironclaw/settings.toml`.
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@@ -385,6 +385,9 @@ async fn run_task_isolated(params: TaskRunParams<'_>) -> TaskResult {
|
||||
ironclaw::agent::cost_guard::CostGuardConfig::default(),
|
||||
));
|
||||
|
||||
let idempotency_cache = Arc::new(ironclaw::tools::ToolIdempotencyCache::new(
|
||||
ironclaw::tools::IdempotencyCacheConfig::default(),
|
||||
));
|
||||
let deps = AgentDeps {
|
||||
store: None,
|
||||
llm: instrumented.clone() as Arc<dyn LlmProvider>,
|
||||
@@ -397,6 +400,7 @@ async fn run_task_isolated(params: TaskRunParams<'_>) -> TaskResult {
|
||||
skills_config: ironclaw::config::SkillsConfig::default(),
|
||||
hooks: Arc::new(ironclaw::hooks::HookRegistry::new()),
|
||||
cost_guard,
|
||||
idempotency_cache,
|
||||
};
|
||||
|
||||
let mut channels = ChannelManager::new();
|
||||
|
||||
@@ -16,9 +16,6 @@ wit-bindgen = "0.36"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Exclude from parent workspace (this is a standalone WASM component)
|
||||
[workspace]
|
||||
|
||||
[profile.release]
|
||||
# Optimize for size
|
||||
opt-level = "s"
|
||||
|
||||
@@ -1038,18 +1038,9 @@ fn handle_message(message: TelegramMessage) {
|
||||
},
|
||||
);
|
||||
|
||||
// Determine what to emit to the agent.
|
||||
// - `/start` (no args): emit a welcome placeholder so the agent greets the user
|
||||
// - Other bare `/commands` (e.g. /interrupt, /help): pass the raw command through
|
||||
// so Submission::parse() can handle it
|
||||
// - Commands with args (e.g. `/start hello`): cleaned_text already has the args
|
||||
// - Plain text: pass through as-is
|
||||
let trimmed_content = content.trim();
|
||||
let content_to_emit = if trimmed_content.eq_ignore_ascii_case("/start") {
|
||||
// For /start with no args, emit placeholder so agent can respond with welcome
|
||||
let content_to_emit = if cleaned_text.is_empty() && content.trim().starts_with('/') {
|
||||
"[User started the bot]".to_string()
|
||||
} else if cleaned_text.is_empty() && trimmed_content.starts_with('/') {
|
||||
// Bare control command like /interrupt, /stop, /help — pass through raw
|
||||
trimmed_content.to_string()
|
||||
} else if cleaned_text.is_empty() {
|
||||
return;
|
||||
} else {
|
||||
@@ -1168,77 +1159,6 @@ mod tests {
|
||||
assert_eq!(clean_message_text("@MyBot", Some("MyBot")), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_message_text_bare_commands() {
|
||||
// Bare commands return empty (the caller decides what to emit)
|
||||
assert_eq!(clean_message_text("/start", None), "");
|
||||
assert_eq!(clean_message_text("/interrupt", None), "");
|
||||
assert_eq!(clean_message_text("/stop", None), "");
|
||||
assert_eq!(clean_message_text("/help", None), "");
|
||||
assert_eq!(clean_message_text("/undo", None), "");
|
||||
assert_eq!(clean_message_text("/ping", None), "");
|
||||
|
||||
// Commands with args: command prefix stripped, args returned
|
||||
assert_eq!(clean_message_text("/start hello", None), "hello");
|
||||
assert_eq!(clean_message_text("/help me please", None), "me please");
|
||||
assert_eq!(clean_message_text("/model claude-opus-4-6", None), "claude-opus-4-6");
|
||||
}
|
||||
|
||||
/// Tests for the content_to_emit logic in handle_message.
|
||||
/// Since handle_message uses WASM host calls, we test the decision logic inline.
|
||||
#[test]
|
||||
fn test_content_to_emit_logic() {
|
||||
// Simulates the content_to_emit decision for various inputs.
|
||||
// This mirrors the logic in handle_message after clean_message_text.
|
||||
fn resolve_content(content: &str) -> Option<String> {
|
||||
let cleaned_text = clean_message_text(content, None);
|
||||
let trimmed_content = content.trim();
|
||||
if trimmed_content.eq_ignore_ascii_case("/start") {
|
||||
Some("[User started the bot]".to_string())
|
||||
} else if cleaned_text.is_empty() && trimmed_content.starts_with('/') {
|
||||
Some(trimmed_content.to_string())
|
||||
} else if cleaned_text.is_empty() {
|
||||
None // would return/skip in handle_message
|
||||
} else {
|
||||
Some(cleaned_text)
|
||||
}
|
||||
}
|
||||
|
||||
// /start → welcome placeholder
|
||||
assert_eq!(resolve_content("/start"), Some("[User started the bot]".to_string()));
|
||||
assert_eq!(resolve_content("/Start"), Some("[User started the bot]".to_string()));
|
||||
assert_eq!(resolve_content(" /start "), Some("[User started the bot]".to_string()));
|
||||
|
||||
// /start with args → pass args through
|
||||
assert_eq!(resolve_content("/start hello"), Some("hello".to_string()));
|
||||
|
||||
// Control commands → pass through raw so Submission::parse() can match
|
||||
assert_eq!(resolve_content("/interrupt"), Some("/interrupt".to_string()));
|
||||
assert_eq!(resolve_content("/stop"), Some("/stop".to_string()));
|
||||
assert_eq!(resolve_content("/help"), Some("/help".to_string()));
|
||||
assert_eq!(resolve_content("/undo"), Some("/undo".to_string()));
|
||||
assert_eq!(resolve_content("/redo"), Some("/redo".to_string()));
|
||||
assert_eq!(resolve_content("/ping"), Some("/ping".to_string()));
|
||||
assert_eq!(resolve_content("/tools"), Some("/tools".to_string()));
|
||||
assert_eq!(resolve_content("/compact"), Some("/compact".to_string()));
|
||||
assert_eq!(resolve_content("/clear"), Some("/clear".to_string()));
|
||||
assert_eq!(resolve_content("/version"), Some("/version".to_string()));
|
||||
|
||||
// Commands with args → cleaned text (command stripped)
|
||||
assert_eq!(resolve_content("/help me please"), Some("me please".to_string()));
|
||||
|
||||
// Plain text → pass through
|
||||
assert_eq!(resolve_content("hello world"), Some("hello world".to_string()));
|
||||
assert_eq!(resolve_content("just text"), Some("just text".to_string()));
|
||||
|
||||
// Empty / whitespace → skip (None)
|
||||
assert_eq!(resolve_content(""), None);
|
||||
assert_eq!(resolve_content(" "), None);
|
||||
|
||||
// Bare @mention without bot → skip
|
||||
assert_eq!(resolve_content("@botname"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_with_owner_id() {
|
||||
let json = r#"{"owner_id": 123456789}"#;
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::hooks::HookRegistry;
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::skills::SkillRegistry;
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::tools::{ToolIdempotencyCache, ToolRegistry};
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
/// Collapse a tool output string into a single-line preview for display.
|
||||
@@ -72,6 +72,8 @@ pub struct AgentDeps {
|
||||
pub hooks: Arc<HookRegistry>,
|
||||
/// Cost enforcement guardrails (daily budget, hourly rate limits).
|
||||
pub cost_guard: Arc<crate::agent::cost_guard::CostGuard>,
|
||||
/// Idempotency cache for tool executions.
|
||||
pub idempotency_cache: Arc<ToolIdempotencyCache>,
|
||||
}
|
||||
|
||||
/// The main agent that coordinates all components.
|
||||
@@ -115,6 +117,7 @@ impl Agent {
|
||||
deps.tools.clone(),
|
||||
deps.store.clone(),
|
||||
deps.hooks.clone(),
|
||||
deps.idempotency_cache.clone(),
|
||||
));
|
||||
|
||||
Self {
|
||||
|
||||
@@ -478,6 +478,24 @@ impl Agent {
|
||||
.into());
|
||||
}
|
||||
|
||||
// Check idempotency cache before executing.
|
||||
// Chat tools use the job_ctx.job_id (an ephemeral UUID per chat turn).
|
||||
if tool.is_idempotent()
|
||||
&& let Some(cached) = self
|
||||
.deps
|
||||
.idempotency_cache
|
||||
.get(job_ctx.job_id, tool_name, params)
|
||||
.await
|
||||
{
|
||||
return serde_json::to_string_pretty(&cached.result).map_err(|e| {
|
||||
crate::error::ToolError::ExecutionFailed {
|
||||
name: tool_name.to_string(),
|
||||
reason: format!("Failed to serialize cached result: {}", e),
|
||||
}
|
||||
.into()
|
||||
});
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
tool = %tool_name,
|
||||
params = %params,
|
||||
@@ -495,6 +513,14 @@ impl Agent {
|
||||
|
||||
match &result {
|
||||
Ok(Ok(output)) => {
|
||||
// Cache successful results for idempotent tools
|
||||
if tool.is_idempotent() {
|
||||
self.deps
|
||||
.idempotency_cache
|
||||
.put(job_ctx.job_id, tool_name, params, output.clone())
|
||||
.await;
|
||||
}
|
||||
|
||||
let result_str = serde_json::to_string(&output.result)
|
||||
.unwrap_or_else(|_| "<serialize error>".to_string());
|
||||
tracing::debug!(
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::error::{Error, JobError};
|
||||
use crate::hooks::HookRegistry;
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::tools::{ToolIdempotencyCache, ToolRegistry};
|
||||
|
||||
/// Message to send to a worker.
|
||||
#[derive(Debug)]
|
||||
@@ -51,6 +51,7 @@ pub struct Scheduler {
|
||||
tools: Arc<ToolRegistry>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
hooks: Arc<HookRegistry>,
|
||||
idempotency_cache: Arc<ToolIdempotencyCache>,
|
||||
/// Running jobs (main LLM-driven jobs).
|
||||
jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>,
|
||||
/// Running sub-tasks (tool executions, background tasks).
|
||||
@@ -59,6 +60,7 @@ pub struct Scheduler {
|
||||
|
||||
impl Scheduler {
|
||||
/// Create a new scheduler.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
config: AgentConfig,
|
||||
context_manager: Arc<ContextManager>,
|
||||
@@ -67,6 +69,7 @@ impl Scheduler {
|
||||
tools: Arc<ToolRegistry>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
hooks: Arc<HookRegistry>,
|
||||
idempotency_cache: Arc<ToolIdempotencyCache>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
@@ -76,6 +79,7 @@ impl Scheduler {
|
||||
tools,
|
||||
store,
|
||||
hooks,
|
||||
idempotency_cache,
|
||||
jobs: Arc::new(RwLock::new(HashMap::new())),
|
||||
subtasks: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
@@ -123,6 +127,7 @@ impl Scheduler {
|
||||
tools: self.tools.clone(),
|
||||
store: self.store.clone(),
|
||||
hooks: self.hooks.clone(),
|
||||
idempotency_cache: self.idempotency_cache.clone(),
|
||||
timeout: self.config.job_timeout,
|
||||
use_planning: self.config.use_planning,
|
||||
};
|
||||
|
||||
+43
-1
@@ -17,7 +17,7 @@ use crate::llm::{
|
||||
ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection,
|
||||
};
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::tools::{ToolIdempotencyCache, ToolRegistry};
|
||||
|
||||
/// Shared dependencies for worker execution.
|
||||
///
|
||||
@@ -31,6 +31,7 @@ pub struct WorkerDeps {
|
||||
pub tools: Arc<ToolRegistry>,
|
||||
pub store: Option<Arc<dyn Database>>,
|
||||
pub hooks: Arc<HookRegistry>,
|
||||
pub idempotency_cache: Arc<ToolIdempotencyCache>,
|
||||
pub timeout: Duration,
|
||||
pub use_planning: bool,
|
||||
}
|
||||
@@ -154,6 +155,12 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
}
|
||||
}
|
||||
|
||||
// Free cached tool results for this job
|
||||
self.deps
|
||||
.idempotency_cache
|
||||
.invalidate_job(self.job_id)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -454,6 +461,32 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
.into());
|
||||
}
|
||||
|
||||
// Check idempotency cache before executing
|
||||
if tool.is_idempotent()
|
||||
&& let Some(cached) = deps.idempotency_cache.get(job_id, tool_name, ¶ms).await
|
||||
{
|
||||
// Record the cache hit in memory (fire-and-forget)
|
||||
let _ = deps
|
||||
.context_manager
|
||||
.update_memory(job_id, |mem| {
|
||||
let rec = mem.create_action(tool_name, params.clone()).succeed(
|
||||
Some("[idempotency cache hit]".to_string()),
|
||||
cached.result.clone(),
|
||||
cached.duration,
|
||||
);
|
||||
mem.record_action(rec);
|
||||
})
|
||||
.await;
|
||||
|
||||
return serde_json::to_string_pretty(&cached.result).map_err(|e| {
|
||||
crate::error::ToolError::ExecutionFailed {
|
||||
name: tool_name.to_string(),
|
||||
reason: format!("Failed to serialize cached result: {}", e),
|
||||
}
|
||||
.into()
|
||||
});
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
tool = %tool_name,
|
||||
params = %params,
|
||||
@@ -499,6 +532,15 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
}
|
||||
}
|
||||
|
||||
// Cache successful results for idempotent tools
|
||||
if let Ok(Ok(output)) = &result
|
||||
&& tool.is_idempotent()
|
||||
{
|
||||
deps.idempotency_cache
|
||||
.put(job_id, tool_name, ¶ms, output.clone())
|
||||
.await;
|
||||
}
|
||||
|
||||
// Record action in memory and get the ActionRecord for persistence
|
||||
let action = match &result {
|
||||
Ok(Ok(output)) => {
|
||||
|
||||
@@ -40,11 +40,6 @@ impl ValueEstimator {
|
||||
|
||||
/// Check if a job is profitable at a given price.
|
||||
pub fn is_profitable(&self, price: Decimal, estimated_cost: Decimal) -> bool {
|
||||
if price.is_zero() {
|
||||
// With a zero price, the job is only profitable if the cost is negative.
|
||||
// This results in a positive profit and an effectively infinite margin.
|
||||
return estimated_cost < Decimal::ZERO;
|
||||
}
|
||||
let margin = (price - estimated_cost) / price;
|
||||
margin >= self.min_margin
|
||||
}
|
||||
@@ -109,15 +104,4 @@ mod tests {
|
||||
let margin = estimator.calculate_margin(dec!(100.0), dec!(70.0));
|
||||
assert_eq!(margin, dec!(0.30)); // 30%
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profitability_zero_price() {
|
||||
let estimator = ValueEstimator::new();
|
||||
|
||||
// Zero price should return false, not panic
|
||||
assert!(!estimator.is_profitable(Decimal::ZERO, dec!(10.0)));
|
||||
assert!(!estimator.is_profitable(Decimal::ZERO, Decimal::ZERO));
|
||||
// Negative cost with zero price is profitable (we get paid to do it)
|
||||
assert!(estimator.is_profitable(Decimal::ZERO, dec!(-10.0)));
|
||||
}
|
||||
}
|
||||
|
||||
+3
-8
@@ -95,16 +95,11 @@ fn create_openai_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, Ll
|
||||
|
||||
use rig::providers::openai;
|
||||
|
||||
// Use CompletionsClient (Chat Completions API) instead of the default Client
|
||||
// (Responses API). The Responses API path in rig-core panics when tool results
|
||||
// are sent back because ironclaw doesn't thread `call_id` through its ToolCall
|
||||
// type. The Chat Completions API works correctly with the existing code.
|
||||
let client: openai::CompletionsClient = openai::Client::new(oai.api_key.expose_secret())
|
||||
.map_err(|e| LlmError::RequestFailed {
|
||||
let client: openai::Client =
|
||||
openai::Client::new(oai.api_key.expose_secret()).map_err(|e| LlmError::RequestFailed {
|
||||
provider: "openai".to_string(),
|
||||
reason: format!("Failed to create OpenAI client: {}", e),
|
||||
})?
|
||||
.completions_api();
|
||||
})?;
|
||||
|
||||
let model = client.completion_model(&oai.model);
|
||||
tracing::info!("Using OpenAI direct API (model: {})", oai.model);
|
||||
|
||||
+10
-339
@@ -16,7 +16,6 @@ use rig::message::{
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use crate::error::LlmError;
|
||||
use crate::llm::costs;
|
||||
@@ -51,162 +50,6 @@ impl<M: CompletionModel> RigAdapter<M> {
|
||||
|
||||
// -- Type conversion helpers --
|
||||
|
||||
/// Normalize a JSON Schema for OpenAI strict mode compliance.
|
||||
///
|
||||
/// OpenAI strict function calling requires:
|
||||
/// - Every object must have `"additionalProperties": false`
|
||||
/// - `"required"` must list ALL property keys
|
||||
/// - Optional fields use `"type": ["<original>", "null"]` instead of being omitted from `required`
|
||||
/// - Nested objects and array items are recursively normalized
|
||||
///
|
||||
/// This is applied as a clone-and-transform at the provider boundary so the
|
||||
/// original tool definitions remain unchanged for other providers.
|
||||
fn normalize_schema_strict(schema: &JsonValue) -> JsonValue {
|
||||
let mut schema = schema.clone();
|
||||
normalize_schema_recursive(&mut schema);
|
||||
schema
|
||||
}
|
||||
|
||||
fn normalize_schema_recursive(schema: &mut JsonValue) {
|
||||
let obj = match schema.as_object_mut() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Recurse into combinators: anyOf, oneOf, allOf
|
||||
for key in &["anyOf", "oneOf", "allOf"] {
|
||||
if let Some(JsonValue::Array(variants)) = obj.get_mut(*key) {
|
||||
for variant in variants.iter_mut() {
|
||||
normalize_schema_recursive(variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into array items
|
||||
if let Some(items) = obj.get_mut("items") {
|
||||
normalize_schema_recursive(items);
|
||||
}
|
||||
|
||||
// Recurse into `not`, `if`, `then`, `else`
|
||||
for key in &["not", "if", "then", "else"] {
|
||||
if let Some(sub) = obj.get_mut(*key) {
|
||||
normalize_schema_recursive(sub);
|
||||
}
|
||||
}
|
||||
|
||||
// Only apply object-level normalization if this schema has "properties"
|
||||
// (explicit object schema) or type == "object"
|
||||
let is_object = obj
|
||||
.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(|t| t == "object")
|
||||
.unwrap_or(false);
|
||||
let has_properties = obj.contains_key("properties");
|
||||
|
||||
if !is_object && !has_properties {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure "type": "object" is present
|
||||
if !obj.contains_key("type") && has_properties {
|
||||
obj.insert("type".to_string(), JsonValue::String("object".to_string()));
|
||||
}
|
||||
|
||||
// Force additionalProperties: false (overwrite any existing value)
|
||||
obj.insert("additionalProperties".to_string(), JsonValue::Bool(false));
|
||||
|
||||
// Ensure "properties" exists
|
||||
if !obj.contains_key("properties") {
|
||||
obj.insert(
|
||||
"properties".to_string(),
|
||||
JsonValue::Object(serde_json::Map::new()),
|
||||
);
|
||||
}
|
||||
|
||||
// Collect current required set
|
||||
let current_required: std::collections::HashSet<String> = obj
|
||||
.get("required")
|
||||
.and_then(|r| r.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Get all property keys (sorted for deterministic output)
|
||||
let all_keys: Vec<String> = obj
|
||||
.get("properties")
|
||||
.and_then(|p| p.as_object())
|
||||
.map(|props| {
|
||||
let mut keys: Vec<String> = props.keys().cloned().collect();
|
||||
keys.sort();
|
||||
keys
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// For properties NOT in the original required list, make them nullable
|
||||
if let Some(JsonValue::Object(props)) = obj.get_mut("properties") {
|
||||
for key in &all_keys {
|
||||
// Recurse into each property's schema FIRST (before make_nullable,
|
||||
// which may change the type to an array and prevent object detection)
|
||||
if let Some(prop_schema) = props.get_mut(key) {
|
||||
normalize_schema_recursive(prop_schema);
|
||||
}
|
||||
// Then make originally-optional properties nullable
|
||||
if !current_required.contains(key)
|
||||
&& let Some(prop_schema) = props.get_mut(key)
|
||||
{
|
||||
make_nullable(prop_schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set required to ALL property keys
|
||||
let required_value: Vec<JsonValue> = all_keys.into_iter().map(JsonValue::String).collect();
|
||||
obj.insert("required".to_string(), JsonValue::Array(required_value));
|
||||
}
|
||||
|
||||
/// Make a property schema nullable for OpenAI strict mode.
|
||||
///
|
||||
/// If it has a simple `"type": "<T>"`, converts to `"type": ["<T>", "null"]`.
|
||||
/// If it already has an array type, adds "null" if not present.
|
||||
/// Otherwise, wraps with `anyOf: [<existing>, {"type": "null"}]`.
|
||||
fn make_nullable(schema: &mut JsonValue) {
|
||||
let obj = match schema.as_object_mut() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
|
||||
if let Some(type_val) = obj.get("type").cloned() {
|
||||
match type_val {
|
||||
// "type": "string" → "type": ["string", "null"]
|
||||
JsonValue::String(ref t) if t != "null" => {
|
||||
obj.insert("type".to_string(), serde_json::json!([t, "null"]));
|
||||
}
|
||||
// "type": ["string", "integer"] → add "null" if missing
|
||||
JsonValue::Array(ref arr) => {
|
||||
let has_null = arr.iter().any(|v| v.as_str() == Some("null"));
|
||||
if !has_null {
|
||||
let mut new_arr = arr.clone();
|
||||
new_arr.push(JsonValue::String("null".to_string()));
|
||||
obj.insert("type".to_string(), JsonValue::Array(new_arr));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
// No "type" key — wrap with anyOf including null
|
||||
// (handles enum-only, $ref, or combinator schemas)
|
||||
let existing = JsonValue::Object(obj.clone());
|
||||
obj.clear();
|
||||
obj.insert(
|
||||
"anyOf".to_string(),
|
||||
serde_json::json!([existing, {"type": "null"}]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert IronClaw messages to rig-core format.
|
||||
///
|
||||
/// Returns `(preamble, chat_history)` where preamble is extracted from
|
||||
@@ -237,16 +80,11 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage
|
||||
if !msg.content.is_empty() {
|
||||
contents.push(AssistantContent::text(&msg.content));
|
||||
}
|
||||
for (idx, tc) in tool_calls.iter().enumerate() {
|
||||
let tool_call_id =
|
||||
normalized_tool_call_id(Some(tc.id.as_str()), history.len() + idx);
|
||||
contents.push(AssistantContent::ToolCall(
|
||||
rig::message::ToolCall::new(
|
||||
tool_call_id.clone(),
|
||||
ToolFunction::new(tc.name.clone(), tc.arguments.clone()),
|
||||
)
|
||||
.with_call_id(tool_call_id),
|
||||
));
|
||||
for tc in tool_calls {
|
||||
contents.push(AssistantContent::ToolCall(rig::message::ToolCall::new(
|
||||
tc.id.clone(),
|
||||
ToolFunction::new(tc.name.clone(), tc.arguments.clone()),
|
||||
)));
|
||||
}
|
||||
if let Ok(many) = OneOrMany::many(contents) {
|
||||
history.push(RigMessage::Assistant {
|
||||
@@ -263,11 +101,11 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage
|
||||
}
|
||||
crate::llm::Role::Tool => {
|
||||
// Tool result message: wrap as User { ToolResult }
|
||||
let tool_id = normalized_tool_call_id(msg.tool_call_id.as_deref(), history.len());
|
||||
let tool_id = msg.tool_call_id.clone().unwrap_or_default();
|
||||
history.push(RigMessage::User {
|
||||
content: OneOrMany::one(UserContent::ToolResult(RigToolResult {
|
||||
id: tool_id.clone(),
|
||||
call_id: Some(tool_id),
|
||||
id: tool_id,
|
||||
call_id: None,
|
||||
content: OneOrMany::one(ToolResultContent::text(&msg.content)),
|
||||
})),
|
||||
});
|
||||
@@ -278,25 +116,14 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage
|
||||
(preamble, history)
|
||||
}
|
||||
|
||||
/// Responses-style providers require a non-empty tool call ID.
|
||||
fn normalized_tool_call_id(raw: Option<&str>, seed: usize) -> String {
|
||||
match raw.map(str::trim).filter(|id| !id.is_empty()) {
|
||||
Some(id) => id.to_string(),
|
||||
None => format!("generated_tool_call_{seed}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert IronClaw tool definitions to rig-core format.
|
||||
///
|
||||
/// Applies OpenAI strict-mode schema normalization to ensure all tool
|
||||
/// parameter schemas comply with OpenAI's function calling requirements.
|
||||
fn convert_tools(tools: &[IronToolDefinition]) -> Vec<RigToolDefinition> {
|
||||
tools
|
||||
.iter()
|
||||
.map(|t| RigToolDefinition {
|
||||
name: t.name.clone(),
|
||||
description: t.description.clone(),
|
||||
parameters: normalize_schema_strict(&t.parameters),
|
||||
parameters: t.parameters.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -528,13 +355,7 @@ mod tests {
|
||||
assert_eq!(history.len(), 1);
|
||||
// Tool results become User messages in rig-core
|
||||
match &history[0] {
|
||||
RigMessage::User { content } => match content.first() {
|
||||
UserContent::ToolResult(r) => {
|
||||
assert_eq!(r.id, "call_123");
|
||||
assert_eq!(r.call_id.as_deref(), Some("call_123"));
|
||||
}
|
||||
other => panic!("Expected tool result content, got: {:?}", other),
|
||||
},
|
||||
RigMessage::User { .. } => {}
|
||||
other => panic!("Expected User message, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
@@ -554,38 +375,11 @@ mod tests {
|
||||
RigMessage::Assistant { content, .. } => {
|
||||
// Should have both text and tool call
|
||||
assert!(content.iter().count() >= 2);
|
||||
for item in content.iter() {
|
||||
if let AssistantContent::ToolCall(tc) = item {
|
||||
assert_eq!(tc.call_id.as_deref(), Some("call_1"));
|
||||
}
|
||||
}
|
||||
}
|
||||
other => panic!("Expected Assistant message, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_messages_tool_result_without_id_gets_fallback() {
|
||||
let messages = vec![ChatMessage {
|
||||
role: crate::llm::Role::Tool,
|
||||
content: "result text".to_string(),
|
||||
tool_call_id: None,
|
||||
name: Some("search".to_string()),
|
||||
tool_calls: None,
|
||||
}];
|
||||
let (_preamble, history) = convert_messages(&messages);
|
||||
match &history[0] {
|
||||
RigMessage::User { content } => match content.first() {
|
||||
UserContent::ToolResult(r) => {
|
||||
assert!(r.id.starts_with("generated_tool_call_"));
|
||||
assert_eq!(r.call_id.as_deref(), Some(r.id.as_str()));
|
||||
}
|
||||
other => panic!("Expected tool result content, got: {:?}", other),
|
||||
},
|
||||
other => panic!("Expected User message, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_tools() {
|
||||
let tools = vec![IronToolDefinition {
|
||||
@@ -648,129 +442,6 @@ mod tests {
|
||||
assert_eq!(finish, FinishReason::ToolUse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assistant_tool_call_empty_id_gets_generated() {
|
||||
let tc = IronToolCall {
|
||||
id: "".to_string(),
|
||||
name: "search".to_string(),
|
||||
arguments: serde_json::json!({"query": "test"}),
|
||||
};
|
||||
let messages = vec![ChatMessage::assistant_with_tool_calls(None, vec![tc])];
|
||||
let (_preamble, history) = convert_messages(&messages);
|
||||
|
||||
match &history[0] {
|
||||
RigMessage::Assistant { content, .. } => {
|
||||
let tool_call = content.iter().find_map(|c| match c {
|
||||
AssistantContent::ToolCall(tc) => Some(tc),
|
||||
_ => None,
|
||||
});
|
||||
let tc = tool_call.expect("should have a tool call");
|
||||
assert!(!tc.id.is_empty(), "tool call id must not be empty");
|
||||
assert!(
|
||||
tc.id.starts_with("generated_tool_call_"),
|
||||
"empty id should be replaced with generated id, got: {}",
|
||||
tc.id
|
||||
);
|
||||
assert_eq!(tc.call_id.as_deref(), Some(tc.id.as_str()));
|
||||
}
|
||||
other => panic!("Expected Assistant message, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assistant_tool_call_whitespace_id_gets_generated() {
|
||||
let tc = IronToolCall {
|
||||
id: " ".to_string(),
|
||||
name: "search".to_string(),
|
||||
arguments: serde_json::json!({"query": "test"}),
|
||||
};
|
||||
let messages = vec![ChatMessage::assistant_with_tool_calls(None, vec![tc])];
|
||||
let (_preamble, history) = convert_messages(&messages);
|
||||
|
||||
match &history[0] {
|
||||
RigMessage::Assistant { content, .. } => {
|
||||
let tool_call = content.iter().find_map(|c| match c {
|
||||
AssistantContent::ToolCall(tc) => Some(tc),
|
||||
_ => None,
|
||||
});
|
||||
let tc = tool_call.expect("should have a tool call");
|
||||
assert!(
|
||||
tc.id.starts_with("generated_tool_call_"),
|
||||
"whitespace-only id should be replaced, got: {:?}",
|
||||
tc.id
|
||||
);
|
||||
}
|
||||
other => panic!("Expected Assistant message, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assistant_and_tool_result_missing_ids_share_generated_id() {
|
||||
// Simulate: assistant emits a tool call with empty id, then tool
|
||||
// result arrives without an id. Both should get deterministic
|
||||
// generated ids that match (based on their position in history).
|
||||
let tc = IronToolCall {
|
||||
id: "".to_string(),
|
||||
name: "search".to_string(),
|
||||
arguments: serde_json::json!({"query": "test"}),
|
||||
};
|
||||
let assistant_msg = ChatMessage::assistant_with_tool_calls(None, vec![tc]);
|
||||
let tool_result_msg = ChatMessage {
|
||||
role: crate::llm::Role::Tool,
|
||||
content: "search results here".to_string(),
|
||||
tool_call_id: None,
|
||||
name: Some("search".to_string()),
|
||||
tool_calls: None,
|
||||
};
|
||||
let messages = vec![assistant_msg, tool_result_msg];
|
||||
let (_preamble, history) = convert_messages(&messages);
|
||||
|
||||
// Extract the generated call_id from the assistant tool call
|
||||
let assistant_call_id = match &history[0] {
|
||||
RigMessage::Assistant { content, .. } => {
|
||||
let tc = content.iter().find_map(|c| match c {
|
||||
AssistantContent::ToolCall(tc) => Some(tc),
|
||||
_ => None,
|
||||
});
|
||||
tc.expect("should have tool call").id.clone()
|
||||
}
|
||||
other => panic!("Expected Assistant message, got: {:?}", other),
|
||||
};
|
||||
|
||||
// Extract the generated call_id from the tool result
|
||||
let tool_result_call_id = match &history[1] {
|
||||
RigMessage::User { content } => match content.first() {
|
||||
UserContent::ToolResult(r) => r
|
||||
.call_id
|
||||
.clone()
|
||||
.expect("tool result call_id must be present"),
|
||||
other => panic!("Expected ToolResult, got: {:?}", other),
|
||||
},
|
||||
other => panic!("Expected User message, got: {:?}", other),
|
||||
};
|
||||
|
||||
assert!(
|
||||
!assistant_call_id.is_empty(),
|
||||
"assistant call_id must not be empty"
|
||||
);
|
||||
assert!(
|
||||
!tool_result_call_id.is_empty(),
|
||||
"tool result call_id must not be empty"
|
||||
);
|
||||
|
||||
// NOTE: With the current seed-based generation, these IDs will differ
|
||||
// because the assistant tool call uses seed=0 (history.len() at that
|
||||
// point) and the tool result uses seed=1 (history.len() after the
|
||||
// assistant message was pushed). This documents the current behavior.
|
||||
// A future improvement could thread the assistant's generated ID into
|
||||
// the tool result for exact matching.
|
||||
assert_ne!(
|
||||
assistant_call_id, tool_result_call_id,
|
||||
"Current impl generates different IDs for assistant call and tool result \
|
||||
because seeds differ; this documents the known limitation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_saturate_u32() {
|
||||
assert_eq!(saturate_u32(100), 100);
|
||||
|
||||
@@ -1372,6 +1372,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
max_actions_per_hour: config.agent.max_actions_per_hour,
|
||||
},
|
||||
));
|
||||
let idempotency_cache = std::sync::Arc::new(ironclaw::tools::ToolIdempotencyCache::new(
|
||||
ironclaw::tools::IdempotencyCacheConfig::default(),
|
||||
));
|
||||
let deps = AgentDeps {
|
||||
store: db,
|
||||
llm,
|
||||
@@ -1384,6 +1387,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
skills_config: config.skills.clone(),
|
||||
hooks,
|
||||
cost_guard,
|
||||
idempotency_cache,
|
||||
};
|
||||
let agent = Agent::new(
|
||||
config.agent.clone(),
|
||||
|
||||
@@ -282,6 +282,9 @@ impl TestHarnessBuilder {
|
||||
max_actions_per_hour: None,
|
||||
}));
|
||||
|
||||
let idempotency_cache = Arc::new(crate::tools::ToolIdempotencyCache::new(
|
||||
crate::tools::IdempotencyCacheConfig::default(),
|
||||
));
|
||||
let deps = AgentDeps {
|
||||
store: Some(Arc::clone(&db)),
|
||||
llm,
|
||||
@@ -294,6 +297,7 @@ impl TestHarnessBuilder {
|
||||
skills_config: SkillsConfig::default(),
|
||||
hooks,
|
||||
cost_guard,
|
||||
idempotency_cache,
|
||||
};
|
||||
|
||||
TestHarness {
|
||||
|
||||
@@ -46,4 +46,8 @@ impl Tool for EchoTool {
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false // Internal tool, no external data
|
||||
}
|
||||
|
||||
fn is_idempotent(&self) -> bool {
|
||||
true // Pure function: same input always produces same output
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +269,10 @@ impl Tool for ReadFileTool {
|
||||
true // Reading local files should require approval
|
||||
}
|
||||
|
||||
fn is_idempotent(&self) -> bool {
|
||||
true // Read-only file access, safe to cache within TTL
|
||||
}
|
||||
|
||||
fn domain(&self) -> ToolDomain {
|
||||
ToolDomain::Container
|
||||
}
|
||||
@@ -492,6 +496,10 @@ impl Tool for ListDirTool {
|
||||
true // Directory listings can leak filesystem structure
|
||||
}
|
||||
|
||||
fn is_idempotent(&self) -> bool {
|
||||
true // Read-only directory listing, safe to cache within TTL
|
||||
}
|
||||
|
||||
fn domain(&self) -> ToolDomain {
|
||||
ToolDomain::Container
|
||||
}
|
||||
|
||||
@@ -845,6 +845,10 @@ impl Tool for ListJobsTool {
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_idempotent(&self) -> bool {
|
||||
true // Read-only job listing, safe to cache within TTL
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool for checking job status.
|
||||
@@ -924,6 +928,10 @@ impl Tool for JobStatusTool {
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_idempotent(&self) -> bool {
|
||||
true // Read-only status check, safe to cache within TTL
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool for canceling a job.
|
||||
|
||||
@@ -102,6 +102,10 @@ impl Tool for JsonTool {
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false // Internal tool, no external data
|
||||
}
|
||||
|
||||
fn is_idempotent(&self) -> bool {
|
||||
true // Pure transform: same JSON in, same result out
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_json_input(data: &serde_json::Value) -> Result<serde_json::Value, ToolError> {
|
||||
|
||||
@@ -112,6 +112,10 @@ impl Tool for MemorySearchTool {
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false // Internal memory, trusted content
|
||||
}
|
||||
|
||||
fn is_idempotent(&self) -> bool {
|
||||
true // Read-only search, safe to cache
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool for writing to workspace memory.
|
||||
@@ -350,6 +354,10 @@ impl Tool for MemoryReadTool {
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false // Internal memory
|
||||
}
|
||||
|
||||
fn is_idempotent(&self) -> bool {
|
||||
true // Read-only file access, safe to cache
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool for viewing workspace structure as a tree.
|
||||
@@ -469,6 +477,10 @@ impl Tool for MemoryTreeTool {
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false // Internal tool
|
||||
}
|
||||
|
||||
fn is_idempotent(&self) -> bool {
|
||||
true // Read-only tree listing, safe to cache
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "postgres"))]
|
||||
|
||||
@@ -111,4 +111,8 @@ impl Tool for TimeTool {
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false // Internal tool, no external data
|
||||
}
|
||||
|
||||
fn is_idempotent(&self) -> bool {
|
||||
true // TTL handles staleness for time-dependent results
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
//! In-memory idempotency cache for tool executions.
|
||||
//!
|
||||
//! For tools that declare `is_idempotent() == true`, successful results are
|
||||
//! cached per-job so repeated identical calls (common during self-repair
|
||||
//! recovery, stuck job retries, or chat-mode retry loops) return instantly
|
||||
//! without re-executing.
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌──────────────────────────────────────────────────────────┐
|
||||
//! │ ToolIdempotencyCache │
|
||||
//! │ │
|
||||
//! │ get(job_id, tool_name, args) -> Option<ToolOutput> │
|
||||
//! │ put(job_id, tool_name, args, output) │
|
||||
//! │ invalidate_job(job_id) // cleanup on job completion │
|
||||
//! │ │
|
||||
//! │ Internal: Mutex<HashMap<(Uuid, CacheKey), CacheEntry>> │
|
||||
//! │ Key: sha256(tool_name | canonical_json(args)) │
|
||||
//! │ Scoped by job_id │
|
||||
//! │ TTL + max entries per job with LRU eviction │
|
||||
//! └──────────────────────────────────────────────────────────┘
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::tools::ToolOutput;
|
||||
|
||||
/// Configuration for the idempotency cache.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IdempotencyCacheConfig {
|
||||
/// Time-to-live for cache entries.
|
||||
pub ttl: Duration,
|
||||
/// Maximum number of cached entries per job before LRU eviction.
|
||||
pub max_entries_per_job: usize,
|
||||
}
|
||||
|
||||
impl Default for IdempotencyCacheConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ttl: Duration::from_secs(30 * 60), // 30 minutes
|
||||
max_entries_per_job: 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SHA-256 hex digest used as cache key.
|
||||
type CacheKey = String;
|
||||
|
||||
struct CacheEntry {
|
||||
output: ToolOutput,
|
||||
created_at: Instant,
|
||||
last_accessed: Instant,
|
||||
}
|
||||
|
||||
/// Per-job idempotency cache for tool results.
|
||||
///
|
||||
/// Only caches `Ok(ToolOutput)` results. Errors are never cached so retries
|
||||
/// after transient failures get a fresh execution.
|
||||
pub struct ToolIdempotencyCache {
|
||||
/// Map from (job_id, cache_key) -> cached result.
|
||||
entries: Mutex<HashMap<(Uuid, CacheKey), CacheEntry>>,
|
||||
config: IdempotencyCacheConfig,
|
||||
}
|
||||
|
||||
impl ToolIdempotencyCache {
|
||||
/// Create a new cache with the given configuration.
|
||||
pub fn new(config: IdempotencyCacheConfig) -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a deterministic cache key from a tool name and its arguments.
|
||||
///
|
||||
/// `sha256(tool_name | canonical_json(args))`. serde_json produces
|
||||
/// stable output for the same input structure.
|
||||
fn cache_key(tool_name: &str, args: &serde_json::Value) -> CacheKey {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(tool_name.as_bytes());
|
||||
hasher.update(b"|");
|
||||
if let Ok(json) = serde_json::to_string(args) {
|
||||
hasher.update(json.as_bytes());
|
||||
}
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
/// Look up a cached result for a tool invocation within a job.
|
||||
///
|
||||
/// Returns `Some(ToolOutput)` on a cache hit (within TTL), `None` on miss.
|
||||
pub async fn get(
|
||||
&self,
|
||||
job_id: Uuid,
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
) -> Option<ToolOutput> {
|
||||
let key = Self::cache_key(tool_name, args);
|
||||
let now = Instant::now();
|
||||
|
||||
let mut guard = self.entries.lock().await;
|
||||
let compound_key = (job_id, key);
|
||||
|
||||
if let Some(entry) = guard.get_mut(&compound_key) {
|
||||
if now.duration_since(entry.created_at) < self.config.ttl {
|
||||
entry.last_accessed = now;
|
||||
tracing::debug!(
|
||||
tool = %tool_name,
|
||||
job = %job_id,
|
||||
"idempotency cache hit"
|
||||
);
|
||||
return Some(entry.output.clone());
|
||||
}
|
||||
// Expired
|
||||
guard.remove(&compound_key);
|
||||
}
|
||||
|
||||
tracing::trace!(
|
||||
tool = %tool_name,
|
||||
job = %job_id,
|
||||
"idempotency cache miss"
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
/// Store a successful tool result in the cache.
|
||||
///
|
||||
/// Evicts expired entries and applies LRU eviction if the per-job limit
|
||||
/// is exceeded.
|
||||
pub async fn put(
|
||||
&self,
|
||||
job_id: Uuid,
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
output: ToolOutput,
|
||||
) {
|
||||
let key = Self::cache_key(tool_name, args);
|
||||
let now = Instant::now();
|
||||
|
||||
let mut guard = self.entries.lock().await;
|
||||
|
||||
// Evict expired entries for this job
|
||||
guard.retain(|(jid, _), entry| {
|
||||
*jid != job_id || now.duration_since(entry.created_at) < self.config.ttl
|
||||
});
|
||||
|
||||
// Count entries for this job and evict LRU if over capacity
|
||||
let job_count = guard.keys().filter(|(jid, _)| *jid == job_id).count();
|
||||
if job_count >= self.config.max_entries_per_job {
|
||||
// Find the LRU entry for this job
|
||||
let oldest_key = guard
|
||||
.iter()
|
||||
.filter(|((jid, _), _)| *jid == job_id)
|
||||
.min_by_key(|(_, entry)| entry.last_accessed)
|
||||
.map(|(k, _)| k.clone());
|
||||
|
||||
if let Some(k) = oldest_key {
|
||||
guard.remove(&k);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::trace!(
|
||||
tool = %tool_name,
|
||||
job = %job_id,
|
||||
"idempotency cache store"
|
||||
);
|
||||
|
||||
guard.insert(
|
||||
(job_id, key),
|
||||
CacheEntry {
|
||||
output,
|
||||
created_at: now,
|
||||
last_accessed: now,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove all cached entries for a job.
|
||||
///
|
||||
/// Call this when a job completes or fails to free memory.
|
||||
pub async fn invalidate_job(&self, job_id: Uuid) {
|
||||
let mut guard = self.entries.lock().await;
|
||||
let before = guard.len();
|
||||
guard.retain(|(jid, _), _| *jid != job_id);
|
||||
let removed = before - guard.len();
|
||||
if removed > 0 {
|
||||
tracing::debug!(
|
||||
job = %job_id,
|
||||
removed,
|
||||
"idempotency cache invalidated job"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Total number of entries across all jobs.
|
||||
#[cfg(test)]
|
||||
async fn len(&self) -> usize {
|
||||
self.entries.lock().await.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::tools::ToolOutput;
|
||||
use crate::tools::idempotency::{IdempotencyCacheConfig, ToolIdempotencyCache};
|
||||
|
||||
fn make_cache(ttl_ms: u64, max_per_job: usize) -> ToolIdempotencyCache {
|
||||
ToolIdempotencyCache::new(IdempotencyCacheConfig {
|
||||
ttl: Duration::from_millis(ttl_ms),
|
||||
max_entries_per_job: max_per_job,
|
||||
})
|
||||
}
|
||||
|
||||
fn sample_output(text: &str) -> ToolOutput {
|
||||
ToolOutput::text(text, Duration::from_millis(1))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_key_deterministic() {
|
||||
let args = serde_json::json!({"query": "hello", "limit": 5});
|
||||
let k1 = ToolIdempotencyCache::cache_key("memory_search", &args);
|
||||
let k2 = ToolIdempotencyCache::cache_key("memory_search", &args);
|
||||
assert_eq!(k1, k2);
|
||||
assert_eq!(k1.len(), 64); // SHA-256 hex
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_key_varies_by_tool_name() {
|
||||
let args = serde_json::json!({"query": "hello"});
|
||||
let k1 = ToolIdempotencyCache::cache_key("memory_search", &args);
|
||||
let k2 = ToolIdempotencyCache::cache_key("memory_read", &args);
|
||||
assert_ne!(k1, k2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_key_varies_by_args() {
|
||||
let k1 = ToolIdempotencyCache::cache_key("echo", &serde_json::json!({"message": "hello"}));
|
||||
let k2 = ToolIdempotencyCache::cache_key("echo", &serde_json::json!({"message": "world"}));
|
||||
assert_ne!(k1, k2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_hit_returns_stored_output() {
|
||||
let cache = make_cache(60_000, 100);
|
||||
let job = Uuid::new_v4();
|
||||
let args = serde_json::json!({"message": "hi"});
|
||||
|
||||
// Miss
|
||||
assert!(cache.get(job, "echo", &args).await.is_none());
|
||||
|
||||
// Store
|
||||
cache.put(job, "echo", &args, sample_output("hi")).await;
|
||||
|
||||
// Hit
|
||||
let hit = cache.get(job, "echo", &args).await;
|
||||
assert!(hit.is_some());
|
||||
assert_eq!(hit.unwrap().result, serde_json::json!("hi"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_miss_for_different_job() {
|
||||
let cache = make_cache(60_000, 100);
|
||||
let job_a = Uuid::new_v4();
|
||||
let job_b = Uuid::new_v4();
|
||||
let args = serde_json::json!({"message": "hi"});
|
||||
|
||||
cache.put(job_a, "echo", &args, sample_output("hi")).await;
|
||||
|
||||
// Different job should miss
|
||||
assert!(cache.get(job_b, "echo", &args).await.is_none());
|
||||
// Same job should hit
|
||||
assert!(cache.get(job_a, "echo", &args).await.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ttl_expiry() {
|
||||
let cache = make_cache(1, 100); // 1ms TTL
|
||||
let job = Uuid::new_v4();
|
||||
let args = serde_json::json!({"message": "hi"});
|
||||
|
||||
cache.put(job, "echo", &args, sample_output("hi")).await;
|
||||
|
||||
// Wait for TTL to expire
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
assert!(cache.get(job, "echo", &args).await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lru_eviction() {
|
||||
let cache = make_cache(60_000, 2); // max 2 per job
|
||||
let job = Uuid::new_v4();
|
||||
|
||||
// Fill with 2 entries
|
||||
let args_a = serde_json::json!({"n": 1});
|
||||
let args_b = serde_json::json!({"n": 2});
|
||||
cache.put(job, "echo", &args_a, sample_output("a")).await;
|
||||
cache.put(job, "echo", &args_b, sample_output("b")).await;
|
||||
assert_eq!(cache.len().await, 2);
|
||||
|
||||
// Access args_a so args_b becomes the LRU
|
||||
cache.get(job, "echo", &args_a).await;
|
||||
|
||||
// Add a third: should evict args_b (oldest accessed)
|
||||
let args_c = serde_json::json!({"n": 3});
|
||||
cache.put(job, "echo", &args_c, sample_output("c")).await;
|
||||
assert_eq!(cache.len().await, 2);
|
||||
|
||||
// args_b should be gone, args_a and args_c should remain
|
||||
assert!(cache.get(job, "echo", &args_b).await.is_none());
|
||||
assert!(cache.get(job, "echo", &args_a).await.is_some());
|
||||
assert!(cache.get(job, "echo", &args_c).await.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalidate_job_clears_entries() {
|
||||
let cache = make_cache(60_000, 100);
|
||||
let job_a = Uuid::new_v4();
|
||||
let job_b = Uuid::new_v4();
|
||||
let args = serde_json::json!({"message": "hi"});
|
||||
|
||||
cache.put(job_a, "echo", &args, sample_output("a")).await;
|
||||
cache.put(job_b, "echo", &args, sample_output("b")).await;
|
||||
assert_eq!(cache.len().await, 2);
|
||||
|
||||
cache.invalidate_job(job_a).await;
|
||||
|
||||
assert_eq!(cache.len().await, 1);
|
||||
assert!(cache.get(job_a, "echo", &args).await.is_none());
|
||||
assert!(cache.get(job_b, "echo", &args).await.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lru_eviction_does_not_affect_other_jobs() {
|
||||
let cache = make_cache(60_000, 1); // max 1 per job
|
||||
let job_a = Uuid::new_v4();
|
||||
let job_b = Uuid::new_v4();
|
||||
|
||||
let args_1 = serde_json::json!({"n": 1});
|
||||
let args_2 = serde_json::json!({"n": 2});
|
||||
|
||||
cache.put(job_a, "echo", &args_1, sample_output("a1")).await;
|
||||
cache.put(job_b, "echo", &args_1, sample_output("b1")).await;
|
||||
|
||||
// Adding a second entry for job_a should evict job_a's first entry,
|
||||
// but not touch job_b's entry
|
||||
cache.put(job_a, "echo", &args_2, sample_output("a2")).await;
|
||||
|
||||
assert!(cache.get(job_a, "echo", &args_1).await.is_none());
|
||||
assert!(cache.get(job_a, "echo", &args_2).await.is_some());
|
||||
assert!(cache.get(job_b, "echo", &args_1).await.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_config_is_reasonable() {
|
||||
let cfg = IdempotencyCacheConfig::default();
|
||||
assert_eq!(cfg.ttl, Duration::from_secs(30 * 60));
|
||||
assert_eq!(cfg.max_entries_per_job, 500);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
pub mod builder;
|
||||
pub mod builtin;
|
||||
pub mod idempotency;
|
||||
pub mod mcp;
|
||||
pub mod wasm;
|
||||
|
||||
@@ -20,5 +21,6 @@ pub use builder::{
|
||||
LlmSoftwareBuilder, SoftwareBuilder, SoftwareType, Template, TemplateEngine, TemplateType,
|
||||
TestCase, TestHarness, TestResult, TestSuite, ValidationError, ValidationResult, WasmValidator,
|
||||
};
|
||||
pub use idempotency::{IdempotencyCacheConfig, ToolIdempotencyCache};
|
||||
pub use registry::ToolRegistry;
|
||||
pub use tool::{Tool, ToolDomain, ToolError, ToolOutput};
|
||||
|
||||
@@ -194,6 +194,15 @@ pub trait Tool: Send + Sync {
|
||||
Duration::from_secs(60)
|
||||
}
|
||||
|
||||
/// Whether this tool is idempotent (same args always produce the same result).
|
||||
///
|
||||
/// When true, successful results are cached per-job so repeated identical
|
||||
/// calls return the cached result without re-executing. Tools that have
|
||||
/// side effects (shell, file write, HTTP POST) should return false (the default).
|
||||
fn is_idempotent(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Where this tool should execute.
|
||||
///
|
||||
/// `Orchestrator` tools run in the main agent process (safe, no FS access).
|
||||
|
||||
Reference in New Issue
Block a user