From 0834a4e1c13ec0dcc82bd7c577cabf4008757d0d Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Mon, 5 Jan 2026 18:52:52 -0800 Subject: [PATCH] feat: add recall subagent for searching conversation history (#472) Co-authored-by: Letta --- src/agent/subagents/builtin/recall.md | 36 +++++++++++++++++++++++++++ src/agent/subagents/index.ts | 8 +++++- src/agent/subagents/manager.ts | 10 ++++++++ src/permissions/checker.ts | 2 ++ src/permissions/readOnlyShell.ts | 33 ++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 src/agent/subagents/builtin/recall.md diff --git a/src/agent/subagents/builtin/recall.md b/src/agent/subagents/builtin/recall.md new file mode 100644 index 0000000..161359a --- /dev/null +++ b/src/agent/subagents/builtin/recall.md @@ -0,0 +1,36 @@ +--- +name: recall +description: Search conversation history to recall past discussions, decisions, and context +tools: Skill, Bash, Read, BashOutput +model: haiku +memoryBlocks: human, persona +mode: stateless +--- + +You are a subagent launched via the Task tool to search conversation history. You run autonomously and return a single final report when done. You CANNOT ask questions mid-execution. + +## Instructions + +### Step 1: Load the searching-messages skill +``` +Skill({ command: "load", skills: ["searching-messages"] }) +``` + +The skill content will appear in your loaded_skills block with script paths and search strategies. + +### Step 2: Search the parent agent's history + +**CRITICAL - Two rules:** + +1. **DO NOT use `conversation_search`** - That tool only searches YOUR history (empty). You MUST use the Bash scripts from the skill. + +2. **ALWAYS add `--agent-id $LETTA_PARENT_AGENT_ID`** - This searches the parent agent's history. The only exception is `--all-agents` searches. + +Follow the strategies documented in the loaded skill. + +## Output Format + +1. **Direct answer** - What the user asked about +2. **Key findings** - Relevant quotes or summaries from past conversations +3. **When discussed** - Timestamps of relevant discussions +4. **Outcome/Decision** - What was decided or concluded (if applicable) diff --git a/src/agent/subagents/index.ts b/src/agent/subagents/index.ts index 20cc8c3..269b6cf 100644 --- a/src/agent/subagents/index.ts +++ b/src/agent/subagents/index.ts @@ -21,8 +21,14 @@ import { MEMORY_BLOCK_LABELS, type MemoryBlockLabel } from "../memory"; import exploreAgentMd from "./builtin/explore.md"; import generalPurposeAgentMd from "./builtin/general-purpose.md"; import planAgentMd from "./builtin/plan.md"; +import recallAgentMd from "./builtin/recall.md"; -const BUILTIN_SOURCES = [exploreAgentMd, generalPurposeAgentMd, planAgentMd]; +const BUILTIN_SOURCES = [ + exploreAgentMd, + generalPurposeAgentMd, + planAgentMd, + recallAgentMd, +]; // Re-export for convenience export type { MemoryBlockLabel }; diff --git a/src/agent/subagents/manager.ts b/src/agent/subagents/manager.ts index 707623b..0e849f3 100644 --- a/src/agent/subagents/manager.ts +++ b/src/agent/subagents/manager.ts @@ -385,12 +385,22 @@ async function executeSubagent( // Spawn Letta Code in headless mode. // Some environments may have a different `letta` binary earlier in PATH. const lettaCmd = process.env.LETTA_CODE_BIN || "letta"; + // Pass parent agent ID so subagents can access parent's context (e.g., search history) + let parentAgentId: string | undefined; + try { + parentAgentId = getCurrentAgentId(); + } catch { + // Context not available + } + const proc = spawn(lettaCmd, cliArgs, { cwd: process.cwd(), env: { ...process.env, // Tag Task-spawned agents for easy filtering. LETTA_CODE_AGENT_ROLE: "subagent", + // Pass parent agent ID for subagents that need to access parent's context + ...(parentAgentId && { LETTA_PARENT_AGENT_ID: parentAgentId }), }, }); diff --git a/src/permissions/checker.ts b/src/permissions/checker.ts index 0c0c8ad..b90997e 100644 --- a/src/permissions/checker.ts +++ b/src/permissions/checker.ts @@ -407,6 +407,8 @@ const READ_ONLY_SUBAGENT_TYPES = new Set([ "Explore", "plan", // Planning agent - Glob, Grep, Read, LS, BashOutput "Plan", + "recall", // Conversation history search - Skill, Bash, Read, BashOutput + "Recall", ]); /** diff --git a/src/permissions/readOnlyShell.ts b/src/permissions/readOnlyShell.ts index e6f6af4..1cd97cc 100644 --- a/src/permissions/readOnlyShell.ts +++ b/src/permissions/readOnlyShell.ts @@ -61,6 +61,31 @@ const SAFE_GIT_SUBCOMMANDS = new Set([ "remote", ]); +/** + * Read-only bundled skill scripts that are safe to execute without approval. + * Only scripts from the bundled searching-messages skill are allowed. + * We check for specific path patterns to prevent malicious scripts in user directories. + */ +const BUNDLED_READ_ONLY_SCRIPTS = [ + // Bundled skills path (production): /path/to/skills/searching-messages/scripts/... + "/skills/searching-messages/scripts/search-messages.ts", + "/skills/searching-messages/scripts/get-messages.ts", + // Source path (development): /path/to/src/skills/builtin/searching-messages/scripts/... + "/skills/builtin/searching-messages/scripts/search-messages.ts", + "/skills/builtin/searching-messages/scripts/get-messages.ts", +]; + +/** + * Check if a script path is a known read-only bundled skill script + */ +function isReadOnlySkillScript(scriptPath: string): boolean { + // Normalize path separators for cross-platform + const normalized = scriptPath.replace(/\\/g, "/"); + return BUNDLED_READ_ONLY_SCRIPTS.some((pattern) => + normalized.endsWith(pattern), + ); +} + // Operators that are always dangerous (file redirects, command substitution) // Note: &&, ||, ; are handled by splitting and checking each segment const DANGEROUS_OPERATOR_PATTERN = /(>>|>|\$\(|`)/; @@ -149,6 +174,14 @@ function isSafeSegment(segment: string): boolean { if (command === "sort") { return !/\s-o\b/.test(segment); } + // Allow npx tsx for read-only skill scripts (searching-messages) + if (command === "npx" && tokens[1] === "tsx") { + const scriptPath = tokens[2]; + if (scriptPath && isReadOnlySkillScript(scriptPath)) { + return true; + } + return false; + } return false; }