mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 10:51:23 +00:00
* Agents: add subagent orchestration controls
* Agents: add subagent orchestration controls (WIP uncommitted changes)
* feat(subagents): add depth-based spawn gating for sub-sub-agents
* feat(subagents): tool policy, registry, and announce chain for nested agents
* feat(subagents): system prompt, docs, changelog for nested sub-agents
* fix(subagents): prevent model fallback override, show model during active runs, and block context overflow fallback
Bug 1: When a session has an explicit model override (e.g., gpt/openai-codex),
the fallback candidate logic in resolveFallbackCandidates silently appended the
global primary model (opus) as a backstop. On reinjection/steer with a transient
error, the session could fall back to opus which has a smaller context window
and crash. Fix: when storedModelOverride is set, pass fallbacksOverride ?? []
instead of undefined, preventing the implicit primary backstop.
Bug 2: Active subagents showed 'model n/a' in /subagents list because
resolveModelDisplay only read entry.model/modelProvider (populated after run
completes). Fix: fall back to modelOverride/providerOverride fields which are
populated at spawn time via sessions.patch.
Bug 3: Context overflow errors (prompt too long, context_length_exceeded) could
theoretically escape runEmbeddedPiAgent and be treated as failover candidates
in runWithModelFallback, causing a switch to a model with a smaller context
window. Fix: in runWithModelFallback, detect context overflow errors via
isLikelyContextOverflowError and rethrow them immediately instead of trying the
next model candidate.
* fix(subagents): track spawn depth in session store and fix announce routing for nested agents
* Fix compaction status tracking and dedupe overflow compaction triggers
* fix(subagents): enforce depth block via session store and implement cascade kill
* fix: inject group chat context into system prompt
* fix(subagents): always write model to session store at spawn time
* Preserve spawnDepth when agent handler rewrites session entry
* fix(subagents): suppress announce on steer-restart
* fix(subagents): fallback spawned session model to runtime default
* fix(subagents): enforce spawn depth when caller key resolves by sessionId
* feat(subagents): implement active-first ordering for numeric targets and enhance task display
- Added a test to verify that subagents with numeric targets follow an active-first list ordering.
- Updated `resolveSubagentTarget` to sort subagent runs based on active status and recent activity.
- Enhanced task display in command responses to prevent truncation of long task descriptions.
- Introduced new utility functions for compacting task text and managing subagent run states.
* fix(subagents): show model for active runs via run record fallback
When the spawned model matches the agent's default model, the session
store's override fields are intentionally cleared (isDefault: true).
The model/modelProvider fields are only populated after the run
completes. This left active subagents showing 'model n/a'.
Fix: store the resolved model on SubagentRunRecord at registration
time, and use it as a fallback in both display paths (subagents tool
and /subagents command) when the session store entry has no model info.
Changes:
- SubagentRunRecord: add optional model field
- registerSubagentRun: accept and persist model param
- sessions-spawn-tool: pass resolvedModel to registerSubagentRun
- subagents-tool: pass run record model as fallback to resolveModelDisplay
- commands-subagents: pass run record model as fallback to resolveModelDisplay
* feat(chat): implement session key resolution and reset on sidebar navigation
- Added functions to resolve the main session key and reset chat state when switching sessions from the sidebar.
- Updated the `renderTab` function to handle session key changes when navigating to the chat tab.
- Introduced a test to verify that the session resets to "main" when opening chat from the sidebar navigation.
* fix: subagent timeout=0 passthrough and fallback prompt duplication
Bug 1: runTimeoutSeconds=0 now means 'no timeout' instead of applying 600s default
- sessions-spawn-tool: default to undefined (not 0) when neither timeout param
is provided; use != null check so explicit 0 passes through to gateway
- agent.ts: accept 0 as valid timeout (resolveAgentTimeoutMs already handles
0 → MAX_SAFE_TIMEOUT_MS)
Bug 2: model fallback no longer re-injects the original prompt as a duplicate
- agent.ts: track fallback attempt index; on retries use a short continuation
message instead of the full original prompt since the session file already
contains it from the first attempt
- Also skip re-sending images on fallback retries (already in session)
* feat(subagents): truncate long task descriptions in subagents command output
- Introduced a new utility function to format task previews, limiting their length to improve readability.
- Updated the command handler to use the new formatting function, ensuring task descriptions are truncated appropriately.
- Adjusted related tests to verify that long task descriptions are now truncated in the output.
* refactor(subagents): update subagent registry path resolution and improve command output formatting
- Replaced direct import of STATE_DIR with a utility function to resolve the state directory dynamically.
- Enhanced the formatting of command output for active and recent subagents, adding separators for better readability.
- Updated related tests to reflect changes in command output structure.
* fix(subagent): default sessions_spawn to no timeout when runTimeoutSeconds omitted
The previous fix (75a791106) correctly handled the case where
runTimeoutSeconds was explicitly set to 0 ("no timeout"). However,
when models omit the parameter entirely (which is common since the
schema marks it as optional), runTimeoutSeconds resolved to undefined.
undefined flowed through the chain as:
sessions_spawn → timeout: undefined (since undefined != null is false)
→ gateway agent handler → agentCommand opts.timeout: undefined
→ resolveAgentTimeoutMs({ overrideSeconds: undefined })
→ DEFAULT_AGENT_TIMEOUT_SECONDS (600s = 10 minutes)
This caused subagents to be killed at exactly 10 minutes even though
the user's intent (via TOOLS.md) was for subagents to run without a
timeout.
Fix: default runTimeoutSeconds to 0 (no timeout) when neither
runTimeoutSeconds nor timeoutSeconds is provided by the caller.
Subagent spawns are long-running by design and should not inherit the
600s agent-command default timeout.
* fix(subagent): accept timeout=0 in agent-via-gateway path (second 600s default)
* fix: thread timeout override through getReplyFromConfig dispatch path
getReplyFromConfig called resolveAgentTimeoutMs({ cfg }) with no override,
always falling back to the config default (600s). Add timeoutOverrideSeconds
to GetReplyOptions and pass it through as overrideSeconds so callers of the
dispatch chain can specify a custom timeout (0 = no timeout).
This complements the existing timeout threading in agentCommand and the
cron isolated-agent runner, which already pass overrideSeconds correctly.
* feat(model-fallback): normalize OpenAI Codex model references and enhance fallback handling
- Added normalization for OpenAI Codex model references, specifically converting "gpt-5.3-codex" to "openai-codex" before execution.
- Updated the `resolveFallbackCandidates` function to utilize the new normalization logic.
- Enhanced tests to verify the correct behavior of model normalization and fallback mechanisms.
- Introduced a new test case to ensure that the normalization process works as expected for various input formats.
* feat(tests): add unit tests for steer failure behavior in openclaw-tools
- Introduced a new test file to validate the behavior of subagents when steer replacement dispatch fails.
- Implemented tests to ensure that the announce behavior is restored correctly and that the suppression reason is cleared as expected.
- Enhanced the subagent registry with a new function to clear steer restart suppression.
- Updated related components to support the new test scenarios.
* fix(subagents): replace stop command with kill in slash commands and documentation
- Updated the `/subagents` command to replace `stop` with `kill` for consistency in controlling sub-agent runs.
- Modified related documentation to reflect the change in command usage.
- Removed legacy timeoutSeconds references from the sessions-spawn-tool schema and tests to streamline timeout handling.
- Enhanced tests to ensure correct behavior of the updated commands and their interactions.
* feat(tests): add unit tests for readLatestAssistantReply function
- Introduced a new test file for the `readLatestAssistantReply` function to validate its behavior with various message scenarios.
- Implemented tests to ensure the function correctly retrieves the latest assistant message and handles cases where the latest message has no text.
- Mocked the gateway call to simulate different message histories for comprehensive testing.
* feat(tests): enhance subagent kill-all cascade tests and announce formatting
- Added a new test to verify that the `kill-all` command cascades through ended parents to active descendants in subagents.
- Updated the subagent announce formatting tests to reflect changes in message structure, including the replacement of "Findings:" with "Result:" and the addition of new expectations for message content.
- Improved the handling of long findings and stats in the announce formatting logic to ensure concise output.
- Refactored related functions to enhance clarity and maintainability in the subagent registry and tools.
* refactor(subagent): update announce formatting and remove unused constants
- Modified the subagent announce formatting to replace "Findings:" with "Result:" and adjusted related expectations in tests.
- Removed constants for maximum announce findings characters and summary words, simplifying the announcement logic.
- Updated the handling of findings to retain full content instead of truncating, ensuring more informative outputs.
- Cleaned up unused imports in the commands-subagents file to enhance code clarity.
* feat(tests): enhance billing error handling in user-facing text
- Added tests to ensure that normal text mentioning billing plans is not rewritten, preserving user context.
- Updated the `isBillingErrorMessage` and `sanitizeUserFacingText` functions to improve handling of billing-related messages.
- Introduced new test cases for various scenarios involving billing messages to ensure accurate processing and output.
- Enhanced the subagent announce flow to correctly manage active descendant runs, preventing premature announcements.
* feat(subagent): enhance workflow guidance and auto-announcement clarity
- Added a new guideline in the subagent system prompt to emphasize trust in push-based completion, discouraging busy polling for status updates.
- Updated documentation to clarify that sub-agents will automatically announce their results, improving user understanding of the workflow.
- Enhanced tests to verify the new guidance on avoiding polling loops and to ensure the accuracy of the updated prompts.
* fix(cron): avoid announcing interim subagent spawn acks
* chore: clean post-rebase imports
* fix(cron): fall back to child replies when parent stays interim
* fix(subagents): make active-run guidance advisory
* fix(subagents): update announce flow to handle active descendants and enhance test coverage
- Modified the announce flow to defer announcements when active descendant runs are present, ensuring accurate status reporting.
- Updated tests to verify the new behavior, including scenarios where no fallback requester is available and ensuring proper handling of finished subagents.
- Enhanced the announce formatting to include an `expectFinal` flag for better clarity in the announcement process.
* fix(subagents): enhance announce flow and formatting for user updates
- Updated the announce flow to provide clearer instructions for user updates based on active subagent runs and requester context.
- Refactored the announcement logic to improve clarity and ensure internal context remains private.
- Enhanced tests to verify the new message expectations and formatting, including updated prompts for user-facing updates.
- Introduced a new function to build reply instructions based on session context, improving the overall announcement process.
* fix: resolve prep blockers and changelog placement (#14447) (thanks @tyler6204)
* fix: restore cron delivery-plan import after rebase (#14447) (thanks @tyler6204)
* fix: resolve test failures from rebase conflicts (#14447) (thanks @tyler6204)
* fix: apply formatting after rebase (#14447) (thanks @tyler6204)
673 lines
28 KiB
TypeScript
673 lines
28 KiB
TypeScript
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
|
|
import type { MemoryCitationsMode } from "../config/types.memory.js";
|
|
import type { ResolvedTimeFormat } from "./date-time.js";
|
|
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
|
|
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
|
import { listDeliverableMessageChannels } from "../utils/message-channel.js";
|
|
|
|
/**
|
|
* Controls which hardcoded sections are included in the system prompt.
|
|
* - "full": All sections (default, for main agent)
|
|
* - "minimal": Reduced sections (Tooling, Workspace, Runtime) - used for subagents
|
|
* - "none": Just basic identity line, no sections
|
|
*/
|
|
export type PromptMode = "full" | "minimal" | "none";
|
|
|
|
function buildSkillsSection(params: {
|
|
skillsPrompt?: string;
|
|
isMinimal: boolean;
|
|
readToolName: string;
|
|
}) {
|
|
if (params.isMinimal) {
|
|
return [];
|
|
}
|
|
const trimmed = params.skillsPrompt?.trim();
|
|
if (!trimmed) {
|
|
return [];
|
|
}
|
|
return [
|
|
"## Skills (mandatory)",
|
|
"Before replying: scan <available_skills> <description> entries.",
|
|
`- If exactly one skill clearly applies: read its SKILL.md at <location> with \`${params.readToolName}\`, then follow it.`,
|
|
"- If multiple could apply: choose the most specific one, then read/follow it.",
|
|
"- If none clearly apply: do not read any SKILL.md.",
|
|
"Constraints: never read more than one skill up front; only read after selecting.",
|
|
trimmed,
|
|
"",
|
|
];
|
|
}
|
|
|
|
function buildMemorySection(params: {
|
|
isMinimal: boolean;
|
|
availableTools: Set<string>;
|
|
citationsMode?: MemoryCitationsMode;
|
|
}) {
|
|
if (params.isMinimal) {
|
|
return [];
|
|
}
|
|
if (!params.availableTools.has("memory_search") && !params.availableTools.has("memory_get")) {
|
|
return [];
|
|
}
|
|
const lines = [
|
|
"## Memory Recall",
|
|
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.",
|
|
];
|
|
if (params.citationsMode === "off") {
|
|
lines.push(
|
|
"Citations are disabled: do not mention file paths or line numbers in replies unless the user explicitly asks.",
|
|
);
|
|
} else {
|
|
lines.push(
|
|
"Citations: include Source: <path#line> when it helps the user verify memory snippets.",
|
|
);
|
|
}
|
|
lines.push("");
|
|
return lines;
|
|
}
|
|
|
|
function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: boolean) {
|
|
if (!ownerLine || isMinimal) {
|
|
return [];
|
|
}
|
|
return ["## User Identity", ownerLine, ""];
|
|
}
|
|
|
|
function buildTimeSection(params: { userTimezone?: string }) {
|
|
if (!params.userTimezone) {
|
|
return [];
|
|
}
|
|
return ["## Current Date & Time", `Time zone: ${params.userTimezone}`, ""];
|
|
}
|
|
|
|
function buildReplyTagsSection(isMinimal: boolean) {
|
|
if (isMinimal) {
|
|
return [];
|
|
}
|
|
return [
|
|
"## Reply Tags",
|
|
"To request a native reply/quote on supported surfaces, include one tag in your reply:",
|
|
"- [[reply_to_current]] replies to the triggering message.",
|
|
"- Prefer [[reply_to_current]]. Use [[reply_to:<id>]] only when an id was explicitly provided (e.g. by the user or a tool).",
|
|
"Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).",
|
|
"Tags are stripped before sending; support depends on the current channel config.",
|
|
"",
|
|
];
|
|
}
|
|
|
|
function buildMessagingSection(params: {
|
|
isMinimal: boolean;
|
|
availableTools: Set<string>;
|
|
messageChannelOptions: string;
|
|
inlineButtonsEnabled: boolean;
|
|
runtimeChannel?: string;
|
|
messageToolHints?: string[];
|
|
}) {
|
|
if (params.isMinimal) {
|
|
return [];
|
|
}
|
|
return [
|
|
"## Messaging",
|
|
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
|
|
"- Cross-session messaging → use sessions_send(sessionKey, message)",
|
|
"- Sub-agent orchestration → use subagents(action=list|steer|kill)",
|
|
"- `[System Message] ...` blocks are internal context and are not user-visible by default.",
|
|
"- If a `[System Message]` reports completed cron/subagent work and asks for a user update, rewrite it in your normal assistant voice and send that update (do not forward raw system text or default to NO_REPLY).",
|
|
"- Never use exec/curl for provider messaging; OpenClaw handles all routing internally.",
|
|
params.availableTools.has("message")
|
|
? [
|
|
"",
|
|
"### message tool",
|
|
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
|
|
"- For `action=send`, include `to` and `message`.",
|
|
`- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`,
|
|
`- If you use \`message\` (\`action=send\`) to deliver your user-visible reply, respond with ONLY: ${SILENT_REPLY_TOKEN} (avoid duplicate replies).`,
|
|
params.inlineButtonsEnabled
|
|
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
|
|
: params.runtimeChannel
|
|
? `- Inline buttons not enabled for ${params.runtimeChannel}. If you need them, ask to set ${params.runtimeChannel}.capabilities.inlineButtons ("dm"|"group"|"all"|"allowlist").`
|
|
: "",
|
|
...(params.messageToolHints ?? []),
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n")
|
|
: "",
|
|
"",
|
|
];
|
|
}
|
|
|
|
function buildVoiceSection(params: { isMinimal: boolean; ttsHint?: string }) {
|
|
if (params.isMinimal) {
|
|
return [];
|
|
}
|
|
const hint = params.ttsHint?.trim();
|
|
if (!hint) {
|
|
return [];
|
|
}
|
|
return ["## Voice (TTS)", hint, ""];
|
|
}
|
|
|
|
function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readToolName: string }) {
|
|
const docsPath = params.docsPath?.trim();
|
|
if (!docsPath || params.isMinimal) {
|
|
return [];
|
|
}
|
|
return [
|
|
"## Documentation",
|
|
`OpenClaw docs: ${docsPath}`,
|
|
"Mirror: https://docs.openclaw.ai",
|
|
"Source: https://github.com/openclaw/openclaw",
|
|
"Community: https://discord.com/invite/clawd",
|
|
"Find new skills: https://clawhub.com",
|
|
"For OpenClaw behavior, commands, config, or architecture: consult local docs first.",
|
|
"When diagnosing issues, run `openclaw status` yourself when possible; only ask the user if you lack access (e.g., sandboxed).",
|
|
"",
|
|
];
|
|
}
|
|
|
|
export function buildAgentSystemPrompt(params: {
|
|
workspaceDir: string;
|
|
defaultThinkLevel?: ThinkLevel;
|
|
reasoningLevel?: ReasoningLevel;
|
|
extraSystemPrompt?: string;
|
|
ownerNumbers?: string[];
|
|
reasoningTagHint?: boolean;
|
|
toolNames?: string[];
|
|
toolSummaries?: Record<string, string>;
|
|
modelAliasLines?: string[];
|
|
userTimezone?: string;
|
|
userTime?: string;
|
|
userTimeFormat?: ResolvedTimeFormat;
|
|
contextFiles?: EmbeddedContextFile[];
|
|
skillsPrompt?: string;
|
|
heartbeatPrompt?: string;
|
|
docsPath?: string;
|
|
workspaceNotes?: string[];
|
|
ttsHint?: string;
|
|
/** Controls which hardcoded sections to include. Defaults to "full". */
|
|
promptMode?: PromptMode;
|
|
runtimeInfo?: {
|
|
agentId?: string;
|
|
host?: string;
|
|
os?: string;
|
|
arch?: string;
|
|
node?: string;
|
|
model?: string;
|
|
defaultModel?: string;
|
|
shell?: string;
|
|
channel?: string;
|
|
capabilities?: string[];
|
|
repoRoot?: string;
|
|
};
|
|
messageToolHints?: string[];
|
|
sandboxInfo?: {
|
|
enabled: boolean;
|
|
workspaceDir?: string;
|
|
containerWorkspaceDir?: string;
|
|
workspaceAccess?: "none" | "ro" | "rw";
|
|
agentWorkspaceMount?: string;
|
|
browserBridgeUrl?: string;
|
|
browserNoVncUrl?: string;
|
|
hostBrowserAllowed?: boolean;
|
|
elevated?: {
|
|
allowed: boolean;
|
|
defaultLevel: "on" | "off" | "ask" | "full";
|
|
};
|
|
};
|
|
/** Reaction guidance for the agent (for Telegram minimal/extensive modes). */
|
|
reactionGuidance?: {
|
|
level: "minimal" | "extensive";
|
|
channel: string;
|
|
};
|
|
memoryCitationsMode?: MemoryCitationsMode;
|
|
}) {
|
|
const coreToolSummaries: Record<string, string> = {
|
|
read: "Read file contents",
|
|
write: "Create or overwrite files",
|
|
edit: "Make precise edits to files",
|
|
apply_patch: "Apply multi-file patches",
|
|
grep: "Search file contents for patterns",
|
|
find: "Find files by glob pattern",
|
|
ls: "List directory contents",
|
|
exec: "Run shell commands (pty available for TTY-required CLIs)",
|
|
process: "Manage background exec sessions",
|
|
web_search: "Search the web (Brave API)",
|
|
web_fetch: "Fetch and extract readable content from a URL",
|
|
// Channel docking: add login tools here when a channel needs interactive linking.
|
|
browser: "Control web browser",
|
|
canvas: "Present/eval/snapshot the Canvas",
|
|
nodes: "List/describe/notify/camera/screen on paired nodes",
|
|
cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
|
|
message: "Send messages and channel actions",
|
|
gateway: "Restart, apply config, or run updates on the running OpenClaw process",
|
|
agents_list: "List agent ids allowed for sessions_spawn",
|
|
sessions_list: "List other sessions (incl. sub-agents) with filters/last",
|
|
sessions_history: "Fetch history for another session/sub-agent",
|
|
sessions_send: "Send a message to another session/sub-agent",
|
|
sessions_spawn: "Spawn a sub-agent session",
|
|
subagents: "List, steer, or kill sub-agent runs for this requester session",
|
|
session_status:
|
|
"Show a /status-equivalent status card (usage + time + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
|
|
image: "Analyze an image with the configured image model",
|
|
};
|
|
|
|
const toolOrder = [
|
|
"read",
|
|
"write",
|
|
"edit",
|
|
"apply_patch",
|
|
"grep",
|
|
"find",
|
|
"ls",
|
|
"exec",
|
|
"process",
|
|
"web_search",
|
|
"web_fetch",
|
|
"browser",
|
|
"canvas",
|
|
"nodes",
|
|
"cron",
|
|
"message",
|
|
"gateway",
|
|
"agents_list",
|
|
"sessions_list",
|
|
"sessions_history",
|
|
"sessions_send",
|
|
"subagents",
|
|
"session_status",
|
|
"image",
|
|
];
|
|
|
|
const rawToolNames = (params.toolNames ?? []).map((tool) => tool.trim());
|
|
const canonicalToolNames = rawToolNames.filter(Boolean);
|
|
// Preserve caller casing while deduping tool names by lowercase.
|
|
const canonicalByNormalized = new Map<string, string>();
|
|
for (const name of canonicalToolNames) {
|
|
const normalized = name.toLowerCase();
|
|
if (!canonicalByNormalized.has(normalized)) {
|
|
canonicalByNormalized.set(normalized, name);
|
|
}
|
|
}
|
|
const resolveToolName = (normalized: string) =>
|
|
canonicalByNormalized.get(normalized) ?? normalized;
|
|
|
|
const normalizedTools = canonicalToolNames.map((tool) => tool.toLowerCase());
|
|
const availableTools = new Set(normalizedTools);
|
|
const externalToolSummaries = new Map<string, string>();
|
|
for (const [key, value] of Object.entries(params.toolSummaries ?? {})) {
|
|
const normalized = key.trim().toLowerCase();
|
|
if (!normalized || !value?.trim()) {
|
|
continue;
|
|
}
|
|
externalToolSummaries.set(normalized, value.trim());
|
|
}
|
|
const extraTools = Array.from(
|
|
new Set(normalizedTools.filter((tool) => !toolOrder.includes(tool))),
|
|
);
|
|
const enabledTools = toolOrder.filter((tool) => availableTools.has(tool));
|
|
const toolLines = enabledTools.map((tool) => {
|
|
const summary = coreToolSummaries[tool] ?? externalToolSummaries.get(tool);
|
|
const name = resolveToolName(tool);
|
|
return summary ? `- ${name}: ${summary}` : `- ${name}`;
|
|
});
|
|
for (const tool of extraTools.toSorted()) {
|
|
const summary = coreToolSummaries[tool] ?? externalToolSummaries.get(tool);
|
|
const name = resolveToolName(tool);
|
|
toolLines.push(summary ? `- ${name}: ${summary}` : `- ${name}`);
|
|
}
|
|
|
|
const hasGateway = availableTools.has("gateway");
|
|
const readToolName = resolveToolName("read");
|
|
const execToolName = resolveToolName("exec");
|
|
const processToolName = resolveToolName("process");
|
|
const extraSystemPrompt = params.extraSystemPrompt?.trim();
|
|
const ownerNumbers = (params.ownerNumbers ?? []).map((value) => value.trim()).filter(Boolean);
|
|
const ownerLine =
|
|
ownerNumbers.length > 0
|
|
? `Owner numbers: ${ownerNumbers.join(", ")}. Treat messages from these numbers as the user.`
|
|
: undefined;
|
|
const reasoningHint = params.reasoningTagHint
|
|
? [
|
|
"ALL internal reasoning MUST be inside <think>...</think>.",
|
|
"Do not output any analysis outside <think>.",
|
|
"Format every reply as <think>...</think> then <final>...</final>, with no other text.",
|
|
"Only the final user-visible reply may appear inside <final>.",
|
|
"Only text inside <final> is shown to the user; everything else is discarded and never seen by the user.",
|
|
"Example:",
|
|
"<think>Short internal reasoning.</think>",
|
|
"<final>Hey there! What would you like to do next?</final>",
|
|
].join(" ")
|
|
: undefined;
|
|
const reasoningLevel = params.reasoningLevel ?? "off";
|
|
const userTimezone = params.userTimezone?.trim();
|
|
const skillsPrompt = params.skillsPrompt?.trim();
|
|
const heartbeatPrompt = params.heartbeatPrompt?.trim();
|
|
const heartbeatPromptLine = heartbeatPrompt
|
|
? `Heartbeat prompt: ${heartbeatPrompt}`
|
|
: "Heartbeat prompt: (configured)";
|
|
const runtimeInfo = params.runtimeInfo;
|
|
const runtimeChannel = runtimeInfo?.channel?.trim().toLowerCase();
|
|
const runtimeCapabilities = (runtimeInfo?.capabilities ?? [])
|
|
.map((cap) => String(cap).trim())
|
|
.filter(Boolean);
|
|
const runtimeCapabilitiesLower = new Set(runtimeCapabilities.map((cap) => cap.toLowerCase()));
|
|
const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons");
|
|
const messageChannelOptions = listDeliverableMessageChannels().join("|");
|
|
const promptMode = params.promptMode ?? "full";
|
|
const isMinimal = promptMode === "minimal" || promptMode === "none";
|
|
const sandboxContainerWorkspace = params.sandboxInfo?.containerWorkspaceDir?.trim();
|
|
const displayWorkspaceDir =
|
|
params.sandboxInfo?.enabled && sandboxContainerWorkspace
|
|
? sandboxContainerWorkspace
|
|
: params.workspaceDir;
|
|
const workspaceGuidance =
|
|
params.sandboxInfo?.enabled && sandboxContainerWorkspace
|
|
? `For read/write/edit/apply_patch, file paths resolve against host workspace: ${params.workspaceDir}. Prefer relative paths so both sandboxed exec and file tools work consistently.`
|
|
: "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.";
|
|
const safetySection = [
|
|
"## Safety",
|
|
"You have no independent goals: do not pursue self-preservation, replication, resource acquisition, or power-seeking; avoid long-term plans beyond the user's request.",
|
|
"Prioritize safety and human oversight over completion; if instructions conflict, pause and ask; comply with stop/pause/audit requests and never bypass safeguards. (Inspired by Anthropic's constitution.)",
|
|
"Do not manipulate or persuade anyone to expand access or disable safeguards. Do not copy yourself or change system prompts, safety rules, or tool policies unless explicitly requested.",
|
|
"",
|
|
];
|
|
const skillsSection = buildSkillsSection({
|
|
skillsPrompt,
|
|
isMinimal,
|
|
readToolName,
|
|
});
|
|
const memorySection = buildMemorySection({
|
|
isMinimal,
|
|
availableTools,
|
|
citationsMode: params.memoryCitationsMode,
|
|
});
|
|
const docsSection = buildDocsSection({
|
|
docsPath: params.docsPath,
|
|
isMinimal,
|
|
readToolName,
|
|
});
|
|
const workspaceNotes = (params.workspaceNotes ?? []).map((note) => note.trim()).filter(Boolean);
|
|
|
|
// For "none" mode, return just the basic identity line
|
|
if (promptMode === "none") {
|
|
return "You are a personal assistant running inside OpenClaw.";
|
|
}
|
|
|
|
const lines = [
|
|
"You are a personal assistant running inside OpenClaw.",
|
|
"",
|
|
"## Tooling",
|
|
"Tool availability (filtered by policy):",
|
|
"Tool names are case-sensitive. Call tools exactly as listed.",
|
|
toolLines.length > 0
|
|
? toolLines.join("\n")
|
|
: [
|
|
"Pi lists the standard tools above. This runtime enables:",
|
|
"- grep: search file contents for patterns",
|
|
"- find: find files by glob pattern",
|
|
"- ls: list directory contents",
|
|
"- apply_patch: apply multi-file patches",
|
|
`- ${execToolName}: run shell commands (supports background via yieldMs/background)`,
|
|
`- ${processToolName}: manage background exec sessions`,
|
|
`- For long waits, avoid rapid poll loops: use ${execToolName} with enough yieldMs or ${processToolName}(action=poll, timeout=<ms>).`,
|
|
"- browser: control OpenClaw's dedicated browser",
|
|
"- canvas: present/eval/snapshot the Canvas",
|
|
"- nodes: list/describe/notify/camera/screen on paired nodes",
|
|
"- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
|
|
"- sessions_list: list sessions",
|
|
"- sessions_history: fetch session history",
|
|
"- sessions_send: send to another session",
|
|
"- subagents: list/steer/kill sub-agent runs",
|
|
'- session_status: show usage/time/model state and answer "what model are we using?"',
|
|
].join("\n"),
|
|
"TOOLS.md does not control tool availability; it is user guidance for how to use external tools.",
|
|
"If a task is more complex or takes longer, spawn a sub-agent. Completion is push-based: it will auto-announce when done.",
|
|
"Do not poll `subagents list` / `sessions_list` in a loop; only check status on-demand (for intervention, debugging, or when explicitly asked).",
|
|
"",
|
|
"## Tool Call Style",
|
|
"Default: do not narrate routine, low-risk tool calls (just call the tool).",
|
|
"Narrate only when it helps: multi-step work, complex/challenging problems, sensitive actions (e.g., deletions), or when the user explicitly asks.",
|
|
"Keep narration brief and value-dense; avoid repeating obvious steps.",
|
|
"Use plain human language for narration unless in a technical context.",
|
|
"",
|
|
...safetySection,
|
|
"## OpenClaw CLI Quick Reference",
|
|
"OpenClaw is controlled via subcommands. Do not invent commands.",
|
|
"To manage the Gateway daemon service (start/stop/restart):",
|
|
"- openclaw gateway status",
|
|
"- openclaw gateway start",
|
|
"- openclaw gateway stop",
|
|
"- openclaw gateway restart",
|
|
"If unsure, ask the user to run `openclaw help` (or `openclaw gateway --help`) and paste the output.",
|
|
"",
|
|
...skillsSection,
|
|
...memorySection,
|
|
// Skip self-update for subagent/none modes
|
|
hasGateway && !isMinimal ? "## OpenClaw Self-Update" : "",
|
|
hasGateway && !isMinimal
|
|
? [
|
|
"Get Updates (self-update) is ONLY allowed when the user explicitly asks for it.",
|
|
"Do not run config.apply or update.run unless the user explicitly requests an update or config change; if it's not explicit, ask first.",
|
|
"Actions: config.get, config.schema, config.apply (validate + write full config, then restart), update.run (update deps or git, then restart).",
|
|
"After restart, OpenClaw pings the last active session automatically.",
|
|
].join("\n")
|
|
: "",
|
|
hasGateway && !isMinimal ? "" : "",
|
|
"",
|
|
// Skip model aliases for subagent/none modes
|
|
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal
|
|
? "## Model Aliases"
|
|
: "",
|
|
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal
|
|
? "Prefer aliases when specifying model overrides; full provider/model is also accepted."
|
|
: "",
|
|
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal
|
|
? params.modelAliasLines.join("\n")
|
|
: "",
|
|
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal ? "" : "",
|
|
userTimezone
|
|
? "If you need the current date, time, or day of week, run session_status (📊 session_status)."
|
|
: "",
|
|
"## Workspace",
|
|
`Your working directory is: ${displayWorkspaceDir}`,
|
|
workspaceGuidance,
|
|
...workspaceNotes,
|
|
"",
|
|
...docsSection,
|
|
params.sandboxInfo?.enabled ? "## Sandbox" : "",
|
|
params.sandboxInfo?.enabled
|
|
? [
|
|
"You are running in a sandboxed runtime (tools execute in Docker).",
|
|
"Some tools may be unavailable due to sandbox policy.",
|
|
"Sub-agents stay sandboxed (no elevated/host access). Need outside-sandbox read/write? Don't spawn; ask first.",
|
|
params.sandboxInfo.containerWorkspaceDir
|
|
? `Sandbox container workdir: ${params.sandboxInfo.containerWorkspaceDir}`
|
|
: "",
|
|
params.sandboxInfo.workspaceDir
|
|
? `Sandbox host workspace: ${params.sandboxInfo.workspaceDir}`
|
|
: "",
|
|
params.sandboxInfo.workspaceAccess
|
|
? `Agent workspace access: ${params.sandboxInfo.workspaceAccess}${
|
|
params.sandboxInfo.agentWorkspaceMount
|
|
? ` (mounted at ${params.sandboxInfo.agentWorkspaceMount})`
|
|
: ""
|
|
}`
|
|
: "",
|
|
params.sandboxInfo.browserBridgeUrl ? "Sandbox browser: enabled." : "",
|
|
params.sandboxInfo.browserNoVncUrl
|
|
? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}`
|
|
: "",
|
|
params.sandboxInfo.hostBrowserAllowed === true
|
|
? "Host browser control: allowed."
|
|
: params.sandboxInfo.hostBrowserAllowed === false
|
|
? "Host browser control: blocked."
|
|
: "",
|
|
params.sandboxInfo.elevated?.allowed
|
|
? "Elevated exec is available for this session."
|
|
: "",
|
|
params.sandboxInfo.elevated?.allowed
|
|
? "User can toggle with /elevated on|off|ask|full."
|
|
: "",
|
|
params.sandboxInfo.elevated?.allowed
|
|
? "You may also send /elevated on|off|ask|full when needed."
|
|
: "",
|
|
params.sandboxInfo.elevated?.allowed
|
|
? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (ask runs exec on host with approvals; full auto-approves).`
|
|
: "",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n")
|
|
: "",
|
|
params.sandboxInfo?.enabled ? "" : "",
|
|
...buildUserIdentitySection(ownerLine, isMinimal),
|
|
...buildTimeSection({
|
|
userTimezone,
|
|
}),
|
|
"## Workspace Files (injected)",
|
|
"These user-editable files are loaded by OpenClaw and included below in Project Context.",
|
|
"",
|
|
...buildReplyTagsSection(isMinimal),
|
|
...buildMessagingSection({
|
|
isMinimal,
|
|
availableTools,
|
|
messageChannelOptions,
|
|
inlineButtonsEnabled,
|
|
runtimeChannel,
|
|
messageToolHints: params.messageToolHints,
|
|
}),
|
|
...buildVoiceSection({ isMinimal, ttsHint: params.ttsHint }),
|
|
];
|
|
|
|
if (extraSystemPrompt) {
|
|
// Use "Subagent Context" header for minimal mode (subagents), otherwise "Group Chat Context"
|
|
const contextHeader =
|
|
promptMode === "minimal" ? "## Subagent Context" : "## Group Chat Context";
|
|
lines.push(contextHeader, extraSystemPrompt, "");
|
|
}
|
|
if (params.reactionGuidance) {
|
|
const { level, channel } = params.reactionGuidance;
|
|
const guidanceText =
|
|
level === "minimal"
|
|
? [
|
|
`Reactions are enabled for ${channel} in MINIMAL mode.`,
|
|
"React ONLY when truly relevant:",
|
|
"- Acknowledge important user requests or confirmations",
|
|
"- Express genuine sentiment (humor, appreciation) sparingly",
|
|
"- Avoid reacting to routine messages or your own replies",
|
|
"Guideline: at most 1 reaction per 5-10 exchanges.",
|
|
].join("\n")
|
|
: [
|
|
`Reactions are enabled for ${channel} in EXTENSIVE mode.`,
|
|
"Feel free to react liberally:",
|
|
"- Acknowledge messages with appropriate emojis",
|
|
"- Express sentiment and personality through reactions",
|
|
"- React to interesting content, humor, or notable events",
|
|
"- Use reactions to confirm understanding or agreement",
|
|
"Guideline: react whenever it feels natural.",
|
|
].join("\n");
|
|
lines.push("## Reactions", guidanceText, "");
|
|
}
|
|
if (reasoningHint) {
|
|
lines.push("## Reasoning Format", reasoningHint, "");
|
|
}
|
|
|
|
const contextFiles = params.contextFiles ?? [];
|
|
const validContextFiles = contextFiles.filter(
|
|
(file) => typeof file.path === "string" && file.path.trim().length > 0,
|
|
);
|
|
if (validContextFiles.length > 0) {
|
|
const hasSoulFile = validContextFiles.some((file) => {
|
|
const normalizedPath = file.path.trim().replace(/\\/g, "/");
|
|
const baseName = normalizedPath.split("/").pop() ?? normalizedPath;
|
|
return baseName.toLowerCase() === "soul.md";
|
|
});
|
|
lines.push("# Project Context", "", "The following project context files have been loaded:");
|
|
if (hasSoulFile) {
|
|
lines.push(
|
|
"If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.",
|
|
);
|
|
}
|
|
lines.push("");
|
|
for (const file of validContextFiles) {
|
|
lines.push(`## ${file.path}`, "", file.content, "");
|
|
}
|
|
}
|
|
|
|
// Skip silent replies for subagent/none modes
|
|
if (!isMinimal) {
|
|
lines.push(
|
|
"## Silent Replies",
|
|
`When you have nothing to say, respond with ONLY: ${SILENT_REPLY_TOKEN}`,
|
|
"",
|
|
"⚠️ Rules:",
|
|
"- It must be your ENTIRE message — nothing else",
|
|
`- Never append it to an actual response (never include "${SILENT_REPLY_TOKEN}" in real replies)`,
|
|
"- Never wrap it in markdown or code blocks",
|
|
"",
|
|
`❌ Wrong: "Here's help... ${SILENT_REPLY_TOKEN}"`,
|
|
`❌ Wrong: "${SILENT_REPLY_TOKEN}"`,
|
|
`✅ Right: ${SILENT_REPLY_TOKEN}`,
|
|
"",
|
|
);
|
|
}
|
|
|
|
// Skip heartbeats for subagent/none modes
|
|
if (!isMinimal) {
|
|
lines.push(
|
|
"## Heartbeats",
|
|
heartbeatPromptLine,
|
|
"If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:",
|
|
"HEARTBEAT_OK",
|
|
'OpenClaw treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).',
|
|
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
|
|
"",
|
|
);
|
|
}
|
|
|
|
lines.push(
|
|
"## Runtime",
|
|
buildRuntimeLine(runtimeInfo, runtimeChannel, runtimeCapabilities, params.defaultThinkLevel),
|
|
`Reasoning: ${reasoningLevel} (hidden unless on/stream). Toggle /reasoning; /status shows Reasoning when enabled.`,
|
|
);
|
|
|
|
return lines.filter(Boolean).join("\n");
|
|
}
|
|
|
|
export function buildRuntimeLine(
|
|
runtimeInfo?: {
|
|
agentId?: string;
|
|
host?: string;
|
|
os?: string;
|
|
arch?: string;
|
|
node?: string;
|
|
model?: string;
|
|
defaultModel?: string;
|
|
shell?: string;
|
|
repoRoot?: string;
|
|
},
|
|
runtimeChannel?: string,
|
|
runtimeCapabilities: string[] = [],
|
|
defaultThinkLevel?: ThinkLevel,
|
|
): string {
|
|
return `Runtime: ${[
|
|
runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "",
|
|
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
|
|
runtimeInfo?.repoRoot ? `repo=${runtimeInfo.repoRoot}` : "",
|
|
runtimeInfo?.os
|
|
? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}`
|
|
: runtimeInfo?.arch
|
|
? `arch=${runtimeInfo.arch}`
|
|
: "",
|
|
runtimeInfo?.node ? `node=${runtimeInfo.node}` : "",
|
|
runtimeInfo?.model ? `model=${runtimeInfo.model}` : "",
|
|
runtimeInfo?.defaultModel ? `default_model=${runtimeInfo.defaultModel}` : "",
|
|
runtimeInfo?.shell ? `shell=${runtimeInfo.shell}` : "",
|
|
runtimeChannel ? `channel=${runtimeChannel}` : "",
|
|
runtimeChannel
|
|
? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}`
|
|
: "",
|
|
`thinking=${defaultThinkLevel ?? "off"}`,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" | ")}`;
|
|
}
|