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.
|
||||
- **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
|
||||
|
||||
@@ -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 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`
|
||||
|
||||
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({
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
cwd: process.cwd(),
|
||||
runtime: {
|
||||
host: "clawdbot",
|
||||
os: `${os.type()} ${os.release()}`,
|
||||
|
||||
@@ -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()}`,
|
||||
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user