feat(agent): enforce provider/model and identity defaults

This commit is contained in:
Peter Steinberger
2025-12-14 04:21:27 +00:00
parent a097c848bb
commit b817225fb8
7 changed files with 150 additions and 21 deletions

View File

@@ -12,6 +12,7 @@ If the file is missing, CLAWDIS uses safe-ish defaults (bundled Pi in RPC mode +
- restrict who can trigger the bot (`inbound.allowFrom`)
- tune group mention behavior (`inbound.groupChat`)
- customize the agent command (`inbound.reply.command`)
- set the agents identity (`identity`)
## Minimal config (recommended starting point)
@@ -25,6 +26,21 @@ If the file is missing, CLAWDIS uses safe-ish defaults (bundled Pi in RPC mode +
## Common options
### `identity`
Optional agent identity used for defaults and UX. This is written by the macOS onboarding assistant.
If set, CLAWDIS derives defaults (only when you havent set them explicitly):
- `inbound.responsePrefix` from `identity.emoji`
- `inbound.groupChat.mentionPatterns` from `identity.name` (so “@Samantha” works in groups)
- `inbound.reply.session.sessionIntro` when `inbound.reply` is present (and for default Pi runs)
```json5
{
identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" }
}
```
### `logging`
- Default log file: `/tmp/clawdis/clawdis-YYYY-MM-DD.log`
@@ -69,9 +85,11 @@ Controls how CLAWDIS produces replies. Two modes:
- `mode: "command"` — run a local command and use its stdout as the reply (typical)
If you **omit** `inbound.reply`, CLAWDIS defaults to the bundled Pi binary in **RPC** mode:
- command: `pi --mode rpc {{BodyStripped}}`
- command (base): `pi --mode rpc {{BodyStripped}}`
- per-sender sessions + `/new` resets
Safety default: when invoking Pi, CLAWDIS always passes `--provider` and `--model` (unless you already specified them).
Example command-mode config:
```json5
@@ -100,6 +118,7 @@ Example command-mode config:
kind: "pi",
format: "json",
// Only used for status/usage labeling (Pi may report its own model)
provider: "anthropic",
model: "claude-opus-4-5",
contextTokens: 200000
}

View File

@@ -1,5 +1,6 @@
// Defaults for agent metadata when upstream does not supply them.
// Model id uses pi-ai's built-in Anthropic catalog.
export const DEFAULT_PROVIDER = "anthropic";
export const DEFAULT_MODEL = "claude-opus-4-5";
// Context window: Opus 4.5 supports ~200k tokens (per pi-ai models.generated.ts).
export const DEFAULT_CONTEXT_TOKENS = 200_000;

View File

@@ -1,5 +1,6 @@
import path from "node:path";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import type {
AgentMeta,
AgentParseResult,
@@ -165,33 +166,51 @@ function parsePiJson(raw: string): AgentParseResult {
};
}
function isPiInvocation(argv: string[]): boolean {
if (argv.length === 0) return false;
const base = path.basename(argv[0]).replace(/\.(m?js)$/i, "");
if (base === "pi" || base === "tau") return true;
// Also handle node entrypoints like `node /.../pi-mono/packages/coding-agent/dist/cli.js`
if (base === "node" && argv.length > 1) {
const second = argv[1]?.toString().toLowerCase();
return (
second.includes("pi-mono") &&
second.includes("packages") &&
second.includes("coding-agent") &&
(second.endsWith("cli.js") || second.includes("/dist/cli"))
);
}
return false;
}
export const piSpec: AgentSpec = {
kind: "pi",
isInvocation: (argv) => {
if (argv.length === 0) return false;
const base = path.basename(argv[0]).replace(/\.(m?js)$/i, "");
if (base === "pi" || base === "tau") return true;
// Also handle node entrypoints like `node /.../pi-mono/packages/coding-agent/dist/cli.js`
if (base === "node" && argv.length > 1) {
const second = argv[1]?.toString().toLowerCase();
return (
second.includes("pi-mono") &&
second.includes("packages") &&
second.includes("coding-agent") &&
(second.endsWith("cli.js") || second.includes("/dist/cli"))
);
}
return false;
},
isInvocation: isPiInvocation,
buildArgs: (ctx) => {
const argv = [...ctx.argv];
if (!isPiInvocation(argv)) return argv;
let bodyPos = ctx.bodyIndex;
const modeIdx = argv.indexOf("--mode");
const modeVal =
modeIdx >= 0 ? argv[modeIdx + 1]?.toString().toLowerCase() : undefined;
const isRpcMode = modeVal === "rpc";
const desiredProvider = (ctx.provider ?? DEFAULT_PROVIDER).trim();
const desiredModel = (ctx.model ?? DEFAULT_MODEL).trim();
const hasFlag = (flag: string) =>
argv.includes(flag) || argv.some((a) => a.startsWith(`${flag}=`));
if (desiredProvider && !hasFlag("--provider")) {
argv.splice(bodyPos, 0, "--provider", desiredProvider);
bodyPos += 2;
}
if (desiredModel && !hasFlag("--model")) {
argv.splice(bodyPos, 0, "--model", desiredModel);
bodyPos += 2;
}
// Non-interactive print + JSON
if (!isRpcMode && !argv.includes("-p") && !argv.includes("--print")) {
argv.splice(bodyPos, 0, "-p");

View File

@@ -34,6 +34,8 @@ export type BuildArgsContext = {
bodyIndex: number; // index of prompt/body argument in argv
isNewSession: boolean;
sessionId?: string;
provider?: string;
model?: string;
sendSystemOnce: boolean;
systemSent: boolean;
identityPrefix?: string;

View File

@@ -482,6 +482,8 @@ export async function runCommandReply(
bodyIndex,
isNewSession,
sessionId: templatingCtx.SessionId,
provider: agentCfg.provider,
model: agentCfg.model,
sendSystemOnce,
systemSent,
identityPrefix: agentCfg.identityPrefix,

View File

@@ -1,7 +1,11 @@
import crypto from "node:crypto";
import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js";
import {
DEFAULT_CONTEXT_TOKENS,
DEFAULT_MODEL,
DEFAULT_PROVIDER,
} from "../agents/defaults.js";
import { resolveBundledPiBinary } from "../agents/pi-path.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
@@ -152,6 +156,7 @@ function makeDefaultPiReply(): ResolvedReplyConfig {
command: [piBin, "--mode", "rpc", "{{BodyStripped}}"],
agent: {
kind: "pi" as const,
provider: DEFAULT_PROVIDER,
model: DEFAULT_MODEL,
contextTokens: defaultContext,
format: "json" as const,
@@ -177,6 +182,18 @@ export async function getReplyFromConfig(
const reply: ResolvedReplyConfig = configuredReply
? { ...configuredReply, cwd: configuredReply.cwd ?? workspaceDir }
: { ...makeDefaultPiReply(), cwd: workspaceDir };
const identity = cfg.identity;
if (identity?.name?.trim() && reply.session && !reply.session.sessionIntro) {
const name = identity.name.trim();
const theme = identity.theme?.trim();
const emoji = identity.emoji?.trim();
const introParts = [
`You are ${name}.`,
theme ? `Theme: ${theme}.` : undefined,
emoji ? `Your emoji is ${emoji}.` : undefined,
].filter(Boolean);
reply.session = { ...reply.session, sessionIntro: introParts.join(" ") };
}
// Bootstrap the workspace (and a starter AGENTS.md) only when we actually run from it.
if (reply.mode === "command" && typeof reply.cwd === "string") {

View File

@@ -85,6 +85,11 @@ export type GroupChatConfig = {
};
export type ClawdisConfig = {
identity?: {
name?: string;
theme?: string;
emoji?: string;
};
logging?: LoggingConfig;
browser?: BrowserConfig;
inbound?: {
@@ -120,6 +125,7 @@ export type ClawdisConfig = {
kind: AgentKind;
format?: "text" | "json";
identityPrefix?: string;
provider?: string;
model?: string;
contextTokens?: number;
};
@@ -185,6 +191,7 @@ const ReplySchema = z
kind: z.literal("pi"),
format: z.union([z.literal("text"), z.literal("json")]).optional(),
identityPrefix: z.string().optional(),
provider: z.string().optional(),
model: z.string().optional(),
contextTokens: z.number().int().positive().optional(),
})
@@ -202,6 +209,13 @@ const ReplySchema = z
);
const ClawdisSchema = z.object({
identity: z
.object({
name: z.string().optional(),
theme: z.string().optional(),
emoji: z.string().optional(),
})
.optional(),
logging: z
.object({
level: z
@@ -291,6 +305,61 @@ const ClawdisSchema = z.object({
.optional(),
});
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function applyIdentityDefaults(cfg: ClawdisConfig): ClawdisConfig {
const identity = cfg.identity;
if (!identity) return cfg;
const emoji = identity.emoji?.trim();
const name = identity.name?.trim();
const theme = identity.theme?.trim();
const inbound = cfg.inbound ?? {};
const groupChat = inbound.groupChat ?? {};
const reply = inbound.reply ?? undefined;
const session = reply?.session ?? undefined;
let mutated = false;
const next: ClawdisConfig = { ...cfg };
if (emoji && !inbound.responsePrefix) {
next.inbound = { ...inbound, responsePrefix: emoji };
mutated = true;
}
if (name && !groupChat.mentionPatterns) {
const parts = name.split(/\s+/).filter(Boolean).map(escapeRegExp);
const re = parts.length ? parts.join("\\s+") : escapeRegExp(name);
const pattern = `\\b@?${re}\\b`;
next.inbound = {
...(next.inbound ?? inbound),
groupChat: { ...groupChat, mentionPatterns: [pattern] },
};
mutated = true;
}
if (name && reply && !session?.sessionIntro) {
const introParts = [
`You are ${name}.`,
theme ? `Theme: ${theme}.` : undefined,
emoji ? `Your emoji is ${emoji}.` : undefined,
].filter(Boolean);
next.inbound = {
...(next.inbound ?? inbound),
reply: {
...reply,
session: { ...(session ?? {}), sessionIntro: introParts.join(" ") },
},
};
mutated = true;
}
return mutated ? next : cfg;
}
export function loadConfig(): ClawdisConfig {
// Read config file (JSON5) if present.
const configPath = CONFIG_PATH_CLAWDIS;
@@ -307,7 +376,7 @@ export function loadConfig(): ClawdisConfig {
}
return {};
}
return validated.data as ClawdisConfig;
return applyIdentityDefaults(validated.data as ClawdisConfig);
} catch (err) {
console.error(`Failed to read config at ${configPath}`, err);
return {};