feat: surface repo root in runtime prompt

This commit is contained in:
Peter Steinberger
2026-01-22 05:07:40 +00:00
parent 8d73c16488
commit e0896de2bf
13 changed files with 197 additions and 2 deletions

View File

@@ -24,7 +24,7 @@ The prompt is intentionally compact and uses fixed sections:
- **Current Date & Time**: user-local time, timezone, and time format.
- **Reply Tags**: optional reply tag syntax for supported providers.
- **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.
## Prompt modes

View File

@@ -1266,6 +1266,18 @@ Default: `~/clawd`.
If `agents.defaults.sandbox` is enabled, non-main sessions can override this with their
own per-scope workspaces under `agents.defaults.sandbox.workspaceRoot`.
### `agents.defaults.repoRoot`
Optional repository root to show in the system prompts 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`
Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`).

View File

@@ -183,6 +183,8 @@ export function buildSystemPrompt(params: {
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.config,
agentId: params.agentId,
workspaceDir: params.workspaceDir,
cwd: process.cwd(),
runtime: {
host: "clawdbot",
os: `${os.type()} ${os.release()}`,

View File

@@ -279,6 +279,8 @@ export async function runEmbeddedAttempt(
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.config,
agentId: sessionAgentId,
workspaceDir: effectiveWorkspace,
cwd: process.cwd(),
runtime: {
host: machineName,
os: `${os.type()} ${os.release()}`,

View 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();
});
});

View File

@@ -1,3 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js";
import {
formatUserTime,
@@ -18,6 +21,7 @@ export type RuntimeInfoInput = {
capabilities?: string[];
/** Supported message actions for the current channel (e.g., react, edit, unsend) */
channelActions?: string[];
repoRoot?: string;
};
export type SystemPromptRuntimeParams = {
@@ -31,7 +35,14 @@ export function buildSystemPromptParams(params: {
config?: ClawdbotConfig;
agentId?: string;
runtime: Omit<RuntimeInfoInput, "agentId">;
workspaceDir?: string;
cwd?: string;
}): SystemPromptRuntimeParams {
const repoRoot = resolveRepoRoot({
config: params.config,
workspaceDir: params.workspaceDir,
cwd: params.cwd,
});
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
@@ -39,9 +50,56 @@ export function buildSystemPromptParams(params: {
runtimeInfo: {
agentId: params.agentId,
...params.runtime,
repoRoot,
},
userTimezone,
userTime,
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;
}

View File

@@ -284,6 +284,7 @@ describe("buildAgentSystemPrompt", () => {
{
agentId: "work",
host: "host",
repoRoot: "/repo",
os: "macOS",
arch: "arm64",
node: "v20",
@@ -297,6 +298,7 @@ describe("buildAgentSystemPrompt", () => {
expect(line).toContain("agent=work");
expect(line).toContain("host=host");
expect(line).toContain("repo=/repo");
expect(line).toContain("os=macOS (arm64)");
expect(line).toContain("node=v20");
expect(line).toContain("model=anthropic/claude");

View File

@@ -160,6 +160,7 @@ export function buildAgentSystemPrompt(params: {
defaultModel?: string;
channel?: string;
capabilities?: string[];
repoRoot?: string;
};
messageToolHints?: string[];
sandboxInfo?: {
@@ -570,6 +571,7 @@ export function buildRuntimeLine(
node?: string;
model?: string;
defaultModel?: string;
repoRoot?: string;
},
runtimeChannel?: string,
runtimeCapabilities: string[] = [],
@@ -578,6 +580,7 @@ export function buildRuntimeLine(
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

View File

@@ -102,6 +102,8 @@ async function resolveContextReport(
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.cfg,
agentId: sessionAgentId,
workspaceDir,
cwd: process.cwd(),
runtime: {
host: "unknown",
os: "unknown",

View File

@@ -197,6 +197,7 @@ const FIELD_LABELS: Record<string, string> = {
"skills.load.watch": "Watch Skills",
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
"agents.defaults.workspace": "Workspace",
"agents.defaults.repoRoot": "Repo Root",
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
"agents.defaults.envelopeTimezone": "Envelope Timezone",
"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).",
"agents.defaults.bootstrapMaxChars":
"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":
'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).',
"agents.defaults.envelopeTimestamp":

View File

@@ -99,6 +99,8 @@ export type AgentDefaultsConfig = {
models?: Record<string, AgentModelEntryConfig>;
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
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. */
skipBootstrap?: boolean;
/** Max chars for injected bootstrap files before truncation (default: 20000). */

View File

@@ -42,6 +42,7 @@ export const AgentDefaultsSchema = z
)
.optional(),
workspace: z.string().optional(),
repoRoot: z.string().optional(),
skipBootstrap: z.boolean().optional(),
bootstrapMaxChars: z.number().int().positive().optional(),
userTimezone: z.string().optional(),

View File

@@ -226,7 +226,9 @@ describe("createTelegramBot", () => {
expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99");
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 () => {
onSpy.mockReset();