feat(agent): enforce provider/model and identity defaults
This commit is contained in:
@@ -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`)
|
- restrict who can trigger the bot (`inbound.allowFrom`)
|
||||||
- tune group mention behavior (`inbound.groupChat`)
|
- tune group mention behavior (`inbound.groupChat`)
|
||||||
- customize the agent command (`inbound.reply.command`)
|
- customize the agent command (`inbound.reply.command`)
|
||||||
|
- set the agent’s identity (`identity`)
|
||||||
|
|
||||||
## Minimal config (recommended starting point)
|
## 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
|
## 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 haven’t 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`
|
### `logging`
|
||||||
|
|
||||||
- Default log file: `/tmp/clawdis/clawdis-YYYY-MM-DD.log`
|
- 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)
|
- `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:
|
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
|
- 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:
|
Example command-mode config:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
@@ -100,6 +118,7 @@ Example command-mode config:
|
|||||||
kind: "pi",
|
kind: "pi",
|
||||||
format: "json",
|
format: "json",
|
||||||
// Only used for status/usage labeling (Pi may report its own model)
|
// Only used for status/usage labeling (Pi may report its own model)
|
||||||
|
provider: "anthropic",
|
||||||
model: "claude-opus-4-5",
|
model: "claude-opus-4-5",
|
||||||
contextTokens: 200000
|
contextTokens: 200000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Defaults for agent metadata when upstream does not supply them.
|
// Defaults for agent metadata when upstream does not supply them.
|
||||||
// Model id uses pi-ai's built-in Anthropic catalog.
|
// Model id uses pi-ai's built-in Anthropic catalog.
|
||||||
|
export const DEFAULT_PROVIDER = "anthropic";
|
||||||
export const DEFAULT_MODEL = "claude-opus-4-5";
|
export const DEFAULT_MODEL = "claude-opus-4-5";
|
||||||
// Context window: Opus 4.5 supports ~200k tokens (per pi-ai models.generated.ts).
|
// Context window: Opus 4.5 supports ~200k tokens (per pi-ai models.generated.ts).
|
||||||
export const DEFAULT_CONTEXT_TOKENS = 200_000;
|
export const DEFAULT_CONTEXT_TOKENS = 200_000;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
||||||
import type {
|
import type {
|
||||||
AgentMeta,
|
AgentMeta,
|
||||||
AgentParseResult,
|
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 = {
|
export const piSpec: AgentSpec = {
|
||||||
kind: "pi",
|
kind: "pi",
|
||||||
isInvocation: (argv) => {
|
isInvocation: isPiInvocation,
|
||||||
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;
|
|
||||||
},
|
|
||||||
buildArgs: (ctx) => {
|
buildArgs: (ctx) => {
|
||||||
const argv = [...ctx.argv];
|
const argv = [...ctx.argv];
|
||||||
|
if (!isPiInvocation(argv)) return argv;
|
||||||
let bodyPos = ctx.bodyIndex;
|
let bodyPos = ctx.bodyIndex;
|
||||||
const modeIdx = argv.indexOf("--mode");
|
const modeIdx = argv.indexOf("--mode");
|
||||||
const modeVal =
|
const modeVal =
|
||||||
modeIdx >= 0 ? argv[modeIdx + 1]?.toString().toLowerCase() : undefined;
|
modeIdx >= 0 ? argv[modeIdx + 1]?.toString().toLowerCase() : undefined;
|
||||||
const isRpcMode = modeVal === "rpc";
|
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
|
// Non-interactive print + JSON
|
||||||
if (!isRpcMode && !argv.includes("-p") && !argv.includes("--print")) {
|
if (!isRpcMode && !argv.includes("-p") && !argv.includes("--print")) {
|
||||||
argv.splice(bodyPos, 0, "-p");
|
argv.splice(bodyPos, 0, "-p");
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export type BuildArgsContext = {
|
|||||||
bodyIndex: number; // index of prompt/body argument in argv
|
bodyIndex: number; // index of prompt/body argument in argv
|
||||||
isNewSession: boolean;
|
isNewSession: boolean;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
sendSystemOnce: boolean;
|
sendSystemOnce: boolean;
|
||||||
systemSent: boolean;
|
systemSent: boolean;
|
||||||
identityPrefix?: string;
|
identityPrefix?: string;
|
||||||
|
|||||||
@@ -482,6 +482,8 @@ export async function runCommandReply(
|
|||||||
bodyIndex,
|
bodyIndex,
|
||||||
isNewSession,
|
isNewSession,
|
||||||
sessionId: templatingCtx.SessionId,
|
sessionId: templatingCtx.SessionId,
|
||||||
|
provider: agentCfg.provider,
|
||||||
|
model: agentCfg.model,
|
||||||
sendSystemOnce,
|
sendSystemOnce,
|
||||||
systemSent,
|
systemSent,
|
||||||
identityPrefix: agentCfg.identityPrefix,
|
identityPrefix: agentCfg.identityPrefix,
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
import { lookupContextTokens } from "../agents/context.js";
|
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 { resolveBundledPiBinary } from "../agents/pi-path.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||||
@@ -152,6 +156,7 @@ function makeDefaultPiReply(): ResolvedReplyConfig {
|
|||||||
command: [piBin, "--mode", "rpc", "{{BodyStripped}}"],
|
command: [piBin, "--mode", "rpc", "{{BodyStripped}}"],
|
||||||
agent: {
|
agent: {
|
||||||
kind: "pi" as const,
|
kind: "pi" as const,
|
||||||
|
provider: DEFAULT_PROVIDER,
|
||||||
model: DEFAULT_MODEL,
|
model: DEFAULT_MODEL,
|
||||||
contextTokens: defaultContext,
|
contextTokens: defaultContext,
|
||||||
format: "json" as const,
|
format: "json" as const,
|
||||||
@@ -177,6 +182,18 @@ export async function getReplyFromConfig(
|
|||||||
const reply: ResolvedReplyConfig = configuredReply
|
const reply: ResolvedReplyConfig = configuredReply
|
||||||
? { ...configuredReply, cwd: configuredReply.cwd ?? workspaceDir }
|
? { ...configuredReply, cwd: configuredReply.cwd ?? workspaceDir }
|
||||||
: { ...makeDefaultPiReply(), 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.
|
// Bootstrap the workspace (and a starter AGENTS.md) only when we actually run from it.
|
||||||
if (reply.mode === "command" && typeof reply.cwd === "string") {
|
if (reply.mode === "command" && typeof reply.cwd === "string") {
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ export type GroupChatConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ClawdisConfig = {
|
export type ClawdisConfig = {
|
||||||
|
identity?: {
|
||||||
|
name?: string;
|
||||||
|
theme?: string;
|
||||||
|
emoji?: string;
|
||||||
|
};
|
||||||
logging?: LoggingConfig;
|
logging?: LoggingConfig;
|
||||||
browser?: BrowserConfig;
|
browser?: BrowserConfig;
|
||||||
inbound?: {
|
inbound?: {
|
||||||
@@ -120,6 +125,7 @@ export type ClawdisConfig = {
|
|||||||
kind: AgentKind;
|
kind: AgentKind;
|
||||||
format?: "text" | "json";
|
format?: "text" | "json";
|
||||||
identityPrefix?: string;
|
identityPrefix?: string;
|
||||||
|
provider?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
contextTokens?: number;
|
contextTokens?: number;
|
||||||
};
|
};
|
||||||
@@ -185,6 +191,7 @@ const ReplySchema = z
|
|||||||
kind: z.literal("pi"),
|
kind: z.literal("pi"),
|
||||||
format: z.union([z.literal("text"), z.literal("json")]).optional(),
|
format: z.union([z.literal("text"), z.literal("json")]).optional(),
|
||||||
identityPrefix: z.string().optional(),
|
identityPrefix: z.string().optional(),
|
||||||
|
provider: z.string().optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
contextTokens: z.number().int().positive().optional(),
|
contextTokens: z.number().int().positive().optional(),
|
||||||
})
|
})
|
||||||
@@ -202,6 +209,13 @@ const ReplySchema = z
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ClawdisSchema = z.object({
|
const ClawdisSchema = z.object({
|
||||||
|
identity: z
|
||||||
|
.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
theme: z.string().optional(),
|
||||||
|
emoji: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
logging: z
|
logging: z
|
||||||
.object({
|
.object({
|
||||||
level: z
|
level: z
|
||||||
@@ -291,6 +305,61 @@ const ClawdisSchema = z.object({
|
|||||||
.optional(),
|
.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 {
|
export function loadConfig(): ClawdisConfig {
|
||||||
// Read config file (JSON5) if present.
|
// Read config file (JSON5) if present.
|
||||||
const configPath = CONFIG_PATH_CLAWDIS;
|
const configPath = CONFIG_PATH_CLAWDIS;
|
||||||
@@ -307,7 +376,7 @@ export function loadConfig(): ClawdisConfig {
|
|||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return validated.data as ClawdisConfig;
|
return applyIdentityDefaults(validated.data as ClawdisConfig);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to read config at ${configPath}`, err);
|
console.error(`Failed to read config at ${configPath}`, err);
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
Reference in New Issue
Block a user