diff --git a/README.md b/README.md index dfdaab2..c8a668c 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,10 @@ no standalone entry point: outside a session the call raises. All runtime configuration enters through the `ai.prime.rlm/runtime-v1` contract object (model, provider credentials, execution policy, prompt configuration, skills, kernel -environment, search credential). Recursive children inherit the parent's configuration +environment, search credential). Prompt configuration is role-aware: optional +`subagent_append_to_system_prompt` (nodes that can still recurse) and +`leaf_append_to_system_prompt` (depth == max_depth) override `append_to_system_prompt` +for sub-agents, each falling back to the next-more-general tier. Recursive children inherit the parent's configuration in-memory (`model_copy`); nothing is re-read from the process environment. The process environment configures only process infrastructure: diff --git a/src/rlm/acp.py b/src/rlm/acp.py index eae034b..26e10f5 100644 --- a/src/rlm/acp.py +++ b/src/rlm/acp.py @@ -70,6 +70,8 @@ class _RuntimeMetadata(_ContractModel): policy: ExecutionPolicy system_prompt_path: str | None append_to_system_prompt: str | None + subagent_append_to_system_prompt: str | None = None + leaf_append_to_system_prompt: str | None = None skills: list[Annotated[str, Field(min_length=1)]] kernel_env: dict[str, str] search_api_key: str | None @@ -201,6 +203,8 @@ def _runtime_config(meta_kwargs: Any) -> tuple[RuntimeConfig, str]: policy=payload.policy, system_prompt_path=payload.system_prompt_path, append_to_system_prompt=payload.append_to_system_prompt, + subagent_append_to_system_prompt=payload.subagent_append_to_system_prompt, + leaf_append_to_system_prompt=payload.leaf_append_to_system_prompt, skills=tuple(payload.skills), kernel_env=tuple(payload.kernel_env.items()), search_api_key=payload.search_api_key, diff --git a/src/rlm/config.py b/src/rlm/config.py index 5b0c6b9..3791757 100644 --- a/src/rlm/config.py +++ b/src/rlm/config.py @@ -91,6 +91,31 @@ class RuntimeConfig(_ConfigModel): policy: ExecutionPolicy system_prompt_path: str | None = None append_to_system_prompt: str | None = None + subagent_append_to_system_prompt: str | None = None + leaf_append_to_system_prompt: str | None = None skills: tuple[str, ...] = () kernel_env: tuple[tuple[str, str], ...] = Field(default=(), repr=False) search_api_key: str | None = Field(default=None, repr=False) + + @property + def resolved_append_to_system_prompt(self) -> str | None: + """The append for this engine's role in the session tree. + + root (depth 0) -> append_to_system_prompt + node (depth >= 1, can still recurse) -> subagent_append_to_system_prompt + leaf (depth == max_depth, cannot recurse) -> leaf_append_to_system_prompt + + Each tier falls back to the next-more-general one (leaf -> subagent -> root), + so any unset append preserves the prior single-append behavior. Mirrors the + allow_recursion gating that build_system_prompt uses for the built-in rlm hint. + """ + if self.invocation.depth == 0: + return self.append_to_system_prompt + if ( + self.invocation.depth >= self.policy.max_depth + and self.leaf_append_to_system_prompt is not None + ): + return self.leaf_append_to_system_prompt + if self.subagent_append_to_system_prompt is not None: + return self.subagent_append_to_system_prompt + return self.append_to_system_prompt diff --git a/src/rlm/engine.py b/src/rlm/engine.py index 8f0f52e..efb25f9 100644 --- a/src/rlm/engine.py +++ b/src/rlm/engine.py @@ -143,7 +143,7 @@ def __init__( self.summarize_at_tokens = config.policy.summarize_at_tokens self.max_compactions = config.policy.max_compactions self.system_prompt_path = config.system_prompt_path - self.append_to_system_prompt = config.append_to_system_prompt + self.append_to_system_prompt = config.resolved_append_to_system_prompt self.max_depth = config.policy.max_depth self.depth = config.invocation.depth self.allow_git = config.policy.allow_git @@ -1001,6 +1001,8 @@ def _load_system_prompt(self, active_tools: list[BuiltinTool]) -> str: self.cwd, str(SKILLS_DIR) if SKILLS_DIR is not None else None, discover_skills(self.session.dir), + depth=self.depth, + session_dir=str(self.session.dir), allow_recursion=self.depth < self.max_depth, allow_git=self.allow_git, active_tools=active_tools, diff --git a/src/rlm/prompt.py b/src/rlm/prompt.py index 9283135..8b7a4d0 100644 --- a/src/rlm/prompt.py +++ b/src/rlm/prompt.py @@ -43,6 +43,11 @@ "Run shell commands with `%%bash` as the very first line of a code cell " "(no comments, imports, or statements before it). " + PROJECT_ENV_PROMPT ) +KERNEL_PACKAGES_PROMPT = ( + "Pre-installed in the kernel venv: " + ", ".join(BASE_TOOLKIT) + ". " + "Install extra packages with `!uv pip install ` in a code cell — that " + "targets the kernel venv (a uv-managed venv with no pip module)." +) BASH_SKILL_PROMPT = ( "Run shell with `out = await bash('''command here''')` — always " "triple-quote the command so shell quotes and multi-line scripts never " @@ -85,6 +90,8 @@ def build_system_prompt( skills_dir: str | None, installed_skills: list[str], *, + depth: int = 0, + session_dir: str | None = None, allow_recursion: bool, allow_git: bool, active_tools: list[BuiltinTool], @@ -92,15 +99,22 @@ def build_system_prompt( ) -> str: """Build the system prompt. - Layout: role → environment (cwd, log path, skills) → capabilities - (recursion) → tool API. Keep it tight: the model also receives the - per-tool schemas, so redundant tool guidance here just inflates + Layout: role → environment (cwd, log path, skills, kernel venv) → + capabilities (recursion) → guards. Keep it tight: the model also receives + the per-tool schemas, so redundant tool guidance here just inflates every request. """ has_bash = _has_tool(active_tools, "bash") has_edit = _has_tool(active_tools, "edit") has_ipython = _has_tool(active_tools, "ipython") - role = "You are a coding agent." + if depth > 0: + role = ( + "You are a coding agent, spawned as a sub-agent: your caller " + "delegated a single task to you and sees none of your work. Do " + "exactly that task; don't widen the scope." + ) + else: + role = "You are a coding agent." if has_bash: role += " You have access to a bash tool for running shell commands." if has_edit: @@ -113,14 +127,22 @@ def build_system_prompt( " You also have an ipython tool: a persistent Python REPL " "(variables, imports, and function definitions persist across calls)." ) + if depth > 0: + done_line = ( + "When the task is done, stop calling tools and state your final " + "answer. It is the only thing your caller receives, so make it a " + "complete, self-contained result — the answer plus the evidence " + "needed to trust it (sources, file paths, values)." + ) + else: + done_line = "When you are done, stop calling tools and state your final answer." + log_dir = session_dir or "$RLM_SESSION_DIR" parts: list[str] = [ role, - "When you are done, stop calling tools and state your final answer.", + done_line, "", f"Working directory: {cwd}", - "Conversation log: $RLM_SESSION_DIR/messages.jsonl", - f"Pre-installed Python packages: {', '.join(BASE_TOOLKIT)}.", - "Install additional packages with `uv pip install ` (this is a uv-managed venv with no pip module).", + f"Conversation log: {log_dir}/messages.jsonl", ] skill_lines: list[str] = [] @@ -132,8 +154,8 @@ def build_system_prompt( installed = ", ".join(f"`{skill}`" for skill in installed_skills) skill_lines.append(f"Installed skills (pre-imported): {installed}.") skill_lines.append( - "Each skill is an async function by the same name. " - "Inspect with `help()` or `inspect.signature(.run)`." + "Each skill is an async function by the same name; " + "inspect one with `help()`." ) shell_skill_set = set(shell_skills or []) if shell_skill_set: @@ -150,6 +172,11 @@ def build_system_prompt( if skill_lines: parts.extend(["", *skill_lines]) + if has_ipython and not has_bash and "bash" not in (installed_skills or []): + parts.extend(["", IPYTHON_CONTROL_PROMPT, KERNEL_PACKAGES_PROMPT]) + elif has_ipython: + parts.extend(["", PROJECT_ENV_PROMPT, KERNEL_PACKAGES_PROMPT]) + if allow_recursion: parts.extend( [ @@ -159,11 +186,6 @@ def build_system_prompt( ] ) - if has_ipython and not has_bash and "bash" not in (installed_skills or []): - parts.extend(["", IPYTHON_CONTROL_PROMPT]) - elif has_ipython: - parts.extend(["", PROJECT_ENV_PROMPT]) - if _should_include_git_history_guard(active_tools, allow_git): parts.extend(["", GIT_HISTORY_GUARD_PROMPT]) diff --git a/src/rlm/tools/ipython.py b/src/rlm/tools/ipython.py index cc7ff13..e0e60a9 100644 --- a/src/rlm/tools/ipython.py +++ b/src/rlm/tools/ipython.py @@ -253,7 +253,7 @@ def _inject_startup(self): skill_names = discover_skills(self.session.dir if self.session else None) setup_code = f"""\ -import os, sys, types, json, time, functools, inspect +import os, sys, asyncio, types, json, time, functools, inspect os.chdir({self.cwd!r}) if {bool(session_dir)!r}: sys.path.append({session_dir!r})