feat: surface repo root in runtime prompt
This commit is contained in:
@@ -24,7 +24,7 @@ The prompt is intentionally compact and uses fixed sections:
|
|||||||
- **Current Date & Time**: user-local time, timezone, and time format.
|
- **Current Date & Time**: user-local time, timezone, and time format.
|
||||||
- **Reply Tags**: optional reply tag syntax for supported providers.
|
- **Reply Tags**: optional reply tag syntax for supported providers.
|
||||||
- **Heartbeats**: heartbeat prompt and ack behavior.
|
- **Heartbeats**: heartbeat prompt and ack behavior.
|
||||||
- **Runtime**: host, OS, node, model, thinking level (one line).
|
- **Runtime**: host, OS, node, model, repo root (when detected), thinking level (one line).
|
||||||
- **Reasoning**: current visibility level + /reasoning toggle hint.
|
- **Reasoning**: current visibility level + /reasoning toggle hint.
|
||||||
|
|
||||||
## Prompt modes
|
## Prompt modes
|
||||||
|
|||||||
@@ -1266,6 +1266,18 @@ Default: `~/clawd`.
|
|||||||
If `agents.defaults.sandbox` is enabled, non-main sessions can override this with their
|
If `agents.defaults.sandbox` is enabled, non-main sessions can override this with their
|
||||||
own per-scope workspaces under `agents.defaults.sandbox.workspaceRoot`.
|
own per-scope workspaces under `agents.defaults.sandbox.workspaceRoot`.
|
||||||
|
|
||||||
|
### `agents.defaults.repoRoot`
|
||||||
|
|
||||||
|
Optional repository root to show in the system prompt’s Runtime line. If unset, Clawdbot
|
||||||
|
tries to detect a `.git` directory by walking upward from the workspace (and current
|
||||||
|
working directory). The path must exist to be used.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agents: { defaults: { repoRoot: "~/Projects/clawdbot" } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### `agents.defaults.skipBootstrap`
|
### `agents.defaults.skipBootstrap`
|
||||||
|
|
||||||
Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`).
|
Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`).
|
||||||
|
|||||||
@@ -183,6 +183,8 @@ export function buildSystemPrompt(params: {
|
|||||||
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
||||||
config: params.config,
|
config: params.config,
|
||||||
agentId: params.agentId,
|
agentId: params.agentId,
|
||||||
|
workspaceDir: params.workspaceDir,
|
||||||
|
cwd: process.cwd(),
|
||||||
runtime: {
|
runtime: {
|
||||||
host: "clawdbot",
|
host: "clawdbot",
|
||||||
os: `${os.type()} ${os.release()}`,
|
os: `${os.type()} ${os.release()}`,
|
||||||
|
|||||||
@@ -279,6 +279,8 @@ export async function runEmbeddedAttempt(
|
|||||||
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
||||||
config: params.config,
|
config: params.config,
|
||||||
agentId: sessionAgentId,
|
agentId: sessionAgentId,
|
||||||
|
workspaceDir: effectiveWorkspace,
|
||||||
|
cwd: process.cwd(),
|
||||||
runtime: {
|
runtime: {
|
||||||
host: machineName,
|
host: machineName,
|
||||||
os: `${os.type()} ${os.release()}`,
|
os: `${os.type()} ${os.release()}`,
|
||||||
|
|||||||
106
src/agents/system-prompt-params.test.ts
Normal file
106
src/agents/system-prompt-params.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { buildSystemPromptParams } from "./system-prompt-params.js";
|
||||||
|
|
||||||
|
async function makeTempDir(label: string): Promise<string> {
|
||||||
|
return fs.mkdtemp(path.join(os.tmpdir(), `clawdbot-${label}-`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeRepoRoot(root: string): Promise<void> {
|
||||||
|
await fs.mkdir(path.join(root, ".git"), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParams(params: { config?: ClawdbotConfig; workspaceDir?: string; cwd?: string }) {
|
||||||
|
return buildSystemPromptParams({
|
||||||
|
config: params.config,
|
||||||
|
workspaceDir: params.workspaceDir,
|
||||||
|
cwd: params.cwd,
|
||||||
|
runtime: {
|
||||||
|
host: "host",
|
||||||
|
os: "os",
|
||||||
|
arch: "arch",
|
||||||
|
node: "node",
|
||||||
|
model: "model",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildSystemPromptParams repo root", () => {
|
||||||
|
it("detects repo root from workspaceDir", async () => {
|
||||||
|
const temp = await makeTempDir("workspace");
|
||||||
|
const repoRoot = path.join(temp, "repo");
|
||||||
|
const workspaceDir = path.join(repoRoot, "nested", "workspace");
|
||||||
|
await fs.mkdir(workspaceDir, { recursive: true });
|
||||||
|
await makeRepoRoot(repoRoot);
|
||||||
|
|
||||||
|
const { runtimeInfo } = buildParams({ workspaceDir });
|
||||||
|
|
||||||
|
expect(runtimeInfo.repoRoot).toBe(repoRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to cwd when workspaceDir has no repo", async () => {
|
||||||
|
const temp = await makeTempDir("cwd");
|
||||||
|
const repoRoot = path.join(temp, "repo");
|
||||||
|
const workspaceDir = path.join(temp, "workspace");
|
||||||
|
await fs.mkdir(workspaceDir, { recursive: true });
|
||||||
|
await makeRepoRoot(repoRoot);
|
||||||
|
|
||||||
|
const { runtimeInfo } = buildParams({ workspaceDir, cwd: repoRoot });
|
||||||
|
|
||||||
|
expect(runtimeInfo.repoRoot).toBe(repoRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses configured repoRoot when valid", async () => {
|
||||||
|
const temp = await makeTempDir("config");
|
||||||
|
const repoRoot = path.join(temp, "config-root");
|
||||||
|
const workspaceDir = path.join(temp, "workspace");
|
||||||
|
await fs.mkdir(repoRoot, { recursive: true });
|
||||||
|
await fs.mkdir(workspaceDir, { recursive: true });
|
||||||
|
await makeRepoRoot(workspaceDir);
|
||||||
|
|
||||||
|
const config: ClawdbotConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
repoRoot,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { runtimeInfo } = buildParams({ config, workspaceDir });
|
||||||
|
|
||||||
|
expect(runtimeInfo.repoRoot).toBe(repoRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores invalid repoRoot config and auto-detects", async () => {
|
||||||
|
const temp = await makeTempDir("invalid");
|
||||||
|
const repoRoot = path.join(temp, "repo");
|
||||||
|
const workspaceDir = path.join(repoRoot, "workspace");
|
||||||
|
await fs.mkdir(workspaceDir, { recursive: true });
|
||||||
|
await makeRepoRoot(repoRoot);
|
||||||
|
|
||||||
|
const config: ClawdbotConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
repoRoot: path.join(temp, "missing"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { runtimeInfo } = buildParams({ config, workspaceDir });
|
||||||
|
|
||||||
|
expect(runtimeInfo.repoRoot).toBe(repoRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when no repo is found", async () => {
|
||||||
|
const workspaceDir = await makeTempDir("norepo");
|
||||||
|
|
||||||
|
const { runtimeInfo } = buildParams({ workspaceDir });
|
||||||
|
|
||||||
|
expect(runtimeInfo.repoRoot).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
formatUserTime,
|
formatUserTime,
|
||||||
@@ -18,6 +21,7 @@ export type RuntimeInfoInput = {
|
|||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
/** Supported message actions for the current channel (e.g., react, edit, unsend) */
|
/** Supported message actions for the current channel (e.g., react, edit, unsend) */
|
||||||
channelActions?: string[];
|
channelActions?: string[];
|
||||||
|
repoRoot?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SystemPromptRuntimeParams = {
|
export type SystemPromptRuntimeParams = {
|
||||||
@@ -31,7 +35,14 @@ export function buildSystemPromptParams(params: {
|
|||||||
config?: ClawdbotConfig;
|
config?: ClawdbotConfig;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
runtime: Omit<RuntimeInfoInput, "agentId">;
|
runtime: Omit<RuntimeInfoInput, "agentId">;
|
||||||
|
workspaceDir?: string;
|
||||||
|
cwd?: string;
|
||||||
}): SystemPromptRuntimeParams {
|
}): SystemPromptRuntimeParams {
|
||||||
|
const repoRoot = resolveRepoRoot({
|
||||||
|
config: params.config,
|
||||||
|
workspaceDir: params.workspaceDir,
|
||||||
|
cwd: params.cwd,
|
||||||
|
});
|
||||||
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
||||||
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
|
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
|
||||||
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
||||||
@@ -39,9 +50,56 @@ export function buildSystemPromptParams(params: {
|
|||||||
runtimeInfo: {
|
runtimeInfo: {
|
||||||
agentId: params.agentId,
|
agentId: params.agentId,
|
||||||
...params.runtime,
|
...params.runtime,
|
||||||
|
repoRoot,
|
||||||
},
|
},
|
||||||
userTimezone,
|
userTimezone,
|
||||||
userTime,
|
userTime,
|
||||||
userTimeFormat,
|
userTimeFormat,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRepoRoot(params: {
|
||||||
|
config?: ClawdbotConfig;
|
||||||
|
workspaceDir?: string;
|
||||||
|
cwd?: string;
|
||||||
|
}): string | undefined {
|
||||||
|
const configured = params.config?.agents?.defaults?.repoRoot?.trim();
|
||||||
|
if (configured) {
|
||||||
|
try {
|
||||||
|
const resolved = path.resolve(configured);
|
||||||
|
const stat = fs.statSync(resolved);
|
||||||
|
if (stat.isDirectory()) return resolved;
|
||||||
|
} catch {
|
||||||
|
// ignore invalid config path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const candidates = [params.workspaceDir, params.cwd]
|
||||||
|
.map((value) => value?.trim())
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const resolved = path.resolve(candidate);
|
||||||
|
if (seen.has(resolved)) continue;
|
||||||
|
seen.add(resolved);
|
||||||
|
const root = findGitRoot(resolved);
|
||||||
|
if (root) return root;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findGitRoot(startDir: string): string | null {
|
||||||
|
let current = path.resolve(startDir);
|
||||||
|
for (let i = 0; i < 12; i += 1) {
|
||||||
|
const gitPath = path.join(current, ".git");
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(gitPath);
|
||||||
|
if (stat.isDirectory() || stat.isFile()) return current;
|
||||||
|
} catch {
|
||||||
|
// ignore missing .git at this level
|
||||||
|
}
|
||||||
|
const parent = path.dirname(current);
|
||||||
|
if (parent === current) break;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ describe("buildAgentSystemPrompt", () => {
|
|||||||
{
|
{
|
||||||
agentId: "work",
|
agentId: "work",
|
||||||
host: "host",
|
host: "host",
|
||||||
|
repoRoot: "/repo",
|
||||||
os: "macOS",
|
os: "macOS",
|
||||||
arch: "arm64",
|
arch: "arm64",
|
||||||
node: "v20",
|
node: "v20",
|
||||||
@@ -297,6 +298,7 @@ describe("buildAgentSystemPrompt", () => {
|
|||||||
|
|
||||||
expect(line).toContain("agent=work");
|
expect(line).toContain("agent=work");
|
||||||
expect(line).toContain("host=host");
|
expect(line).toContain("host=host");
|
||||||
|
expect(line).toContain("repo=/repo");
|
||||||
expect(line).toContain("os=macOS (arm64)");
|
expect(line).toContain("os=macOS (arm64)");
|
||||||
expect(line).toContain("node=v20");
|
expect(line).toContain("node=v20");
|
||||||
expect(line).toContain("model=anthropic/claude");
|
expect(line).toContain("model=anthropic/claude");
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
defaultModel?: string;
|
defaultModel?: string;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
|
repoRoot?: string;
|
||||||
};
|
};
|
||||||
messageToolHints?: string[];
|
messageToolHints?: string[];
|
||||||
sandboxInfo?: {
|
sandboxInfo?: {
|
||||||
@@ -570,6 +571,7 @@ export function buildRuntimeLine(
|
|||||||
node?: string;
|
node?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
defaultModel?: string;
|
defaultModel?: string;
|
||||||
|
repoRoot?: string;
|
||||||
},
|
},
|
||||||
runtimeChannel?: string,
|
runtimeChannel?: string,
|
||||||
runtimeCapabilities: string[] = [],
|
runtimeCapabilities: string[] = [],
|
||||||
@@ -578,6 +580,7 @@ export function buildRuntimeLine(
|
|||||||
return `Runtime: ${[
|
return `Runtime: ${[
|
||||||
runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "",
|
runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "",
|
||||||
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
|
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
|
||||||
|
runtimeInfo?.repoRoot ? `repo=${runtimeInfo.repoRoot}` : "",
|
||||||
runtimeInfo?.os
|
runtimeInfo?.os
|
||||||
? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}`
|
? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}`
|
||||||
: runtimeInfo?.arch
|
: runtimeInfo?.arch
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ async function resolveContextReport(
|
|||||||
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
||||||
config: params.cfg,
|
config: params.cfg,
|
||||||
agentId: sessionAgentId,
|
agentId: sessionAgentId,
|
||||||
|
workspaceDir,
|
||||||
|
cwd: process.cwd(),
|
||||||
runtime: {
|
runtime: {
|
||||||
host: "unknown",
|
host: "unknown",
|
||||||
os: "unknown",
|
os: "unknown",
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"skills.load.watch": "Watch Skills",
|
"skills.load.watch": "Watch Skills",
|
||||||
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
|
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
|
||||||
"agents.defaults.workspace": "Workspace",
|
"agents.defaults.workspace": "Workspace",
|
||||||
|
"agents.defaults.repoRoot": "Repo Root",
|
||||||
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
|
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
|
||||||
"agents.defaults.envelopeTimezone": "Envelope Timezone",
|
"agents.defaults.envelopeTimezone": "Envelope Timezone",
|
||||||
"agents.defaults.envelopeTimestamp": "Envelope Timestamp",
|
"agents.defaults.envelopeTimestamp": "Envelope Timestamp",
|
||||||
@@ -432,6 +433,8 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).",
|
"auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).",
|
||||||
"agents.defaults.bootstrapMaxChars":
|
"agents.defaults.bootstrapMaxChars":
|
||||||
"Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).",
|
"Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).",
|
||||||
|
"agents.defaults.repoRoot":
|
||||||
|
"Optional repository root shown in the system prompt runtime line (overrides auto-detect).",
|
||||||
"agents.defaults.envelopeTimezone":
|
"agents.defaults.envelopeTimezone":
|
||||||
'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).',
|
'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).',
|
||||||
"agents.defaults.envelopeTimestamp":
|
"agents.defaults.envelopeTimestamp":
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ export type AgentDefaultsConfig = {
|
|||||||
models?: Record<string, AgentModelEntryConfig>;
|
models?: Record<string, AgentModelEntryConfig>;
|
||||||
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
|
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
|
||||||
workspace?: string;
|
workspace?: string;
|
||||||
|
/** Optional repository root for system prompt runtime line (overrides auto-detect). */
|
||||||
|
repoRoot?: string;
|
||||||
/** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */
|
/** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */
|
||||||
skipBootstrap?: boolean;
|
skipBootstrap?: boolean;
|
||||||
/** Max chars for injected bootstrap files before truncation (default: 20000). */
|
/** Max chars for injected bootstrap files before truncation (default: 20000). */
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const AgentDefaultsSchema = z
|
|||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
workspace: z.string().optional(),
|
workspace: z.string().optional(),
|
||||||
|
repoRoot: z.string().optional(),
|
||||||
skipBootstrap: z.boolean().optional(),
|
skipBootstrap: z.boolean().optional(),
|
||||||
bootstrapMaxChars: z.number().int().positive().optional(),
|
bootstrapMaxChars: z.number().int().positive().optional(),
|
||||||
userTimezone: z.string().optional(),
|
userTimezone: z.string().optional(),
|
||||||
|
|||||||
@@ -226,7 +226,9 @@ describe("createTelegramBot", () => {
|
|||||||
expect(payload.SenderName).toBe("Ada Lovelace");
|
expect(payload.SenderName).toBe("Ada Lovelace");
|
||||||
expect(payload.SenderId).toBe("99");
|
expect(payload.SenderId).toBe("99");
|
||||||
expect(payload.SenderUsername).toBe("ada");
|
expect(payload.SenderUsername).toBe("ada");
|
||||||
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/);
|
expect(payload.Body).toMatch(
|
||||||
|
/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
|
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
|
|||||||
Reference in New Issue
Block a user