Compare commits

...

2 Commits

Author SHA1 Message Date
gavrielc 60fab764e3 style: apply prettier formatting
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:07:46 +03:00
gavrielc e38e63234e feat(v2): per-group provider config (WIP)
Per-group provider blocks in groups/<folder>/container.json::providers.<name>
take precedence over host .env for model selection. Includes claude.ts
(new, emits ANTHROPIC_MODEL) and opencode.ts per-group overrides, plus
related plumbing and skill doc updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:07:32 +03:00
12 changed files with 297 additions and 22 deletions
+40
View File
@@ -54,6 +54,46 @@ Implementation:
1. Add MCP server config to the container settings (see `src/container-runner.ts` for how MCP servers are mounted)
2. Document available tools in `groups/CLAUDE.md`
### Changing Provider or Model (per agent group)
Each agent group picks its own provider (`claude` | `opencode` | `mock`) and its own model via `providers.<name>.model` in `groups/<folder>/container.json`. There are two paths depending on whether the operator is at the keyboard or wants the agent to change itself from chat.
Questions to ask:
- Which group? (look up by name or folder)
- Switch provider, change model, or both?
- New model id (provider-specific — e.g. `claude-sonnet-4-5`, `openrouter/anthropic/claude-sonnet-4`, `opencode/big-pickle`)
**From Claude Code (operator editing files):**
1. For provider switch: `sqlite3 data/v2.db "UPDATE agent_groups SET agent_provider = '<name>' WHERE folder = '<folder>'"`.
2. For model: edit `groups/<folder>/container.json` and merge into `providers.<provider>`:
```jsonc
{ "providers": { "claude": { "model": "claude-sonnet-4-5" } } }
```
3. Bounce the session's container (kill it or wait for idle) so the next wake picks up the new env.
**From chat (agent reconfigures itself):**
Tell the agent (or let it propose it): "use the `set_provider_config` tool to switch my model to X." The tool writes a system action that triggers an approval card to an admin; after approval, the host writes `container.json` and kills the container so the next message spawns with the new env. For provider switching, the DB column still needs a direct edit (no self-mod tool for `agent_groups.agent_provider` yet).
Shape of the per-group config (what the tool merges into):
```jsonc
// groups/<folder>/container.json
{
"providers": {
"claude": { "model": "claude-sonnet-4-5" },
"opencode": {
"innerProvider": "openrouter",
"model": "openrouter/anthropic/claude-sonnet-4",
"smallModel": "openrouter/anthropic/claude-haiku-4.5"
}
}
}
```
Fallback chain: per-group config → host `.env` (`ANTHROPIC_MODEL` / `OPENCODE_*`) → provider default.
### Changing Assistant Behavior
Questions to ask:
+12 -2
View File
@@ -31,6 +31,14 @@ Then ask in plain text (NOT `AskUserQuestion` — these are free-form):
2. **Your display name** — human name, used to name the agent group (`dm-with-<normalized>`) and as the welcome-message addressee. Record as `DISPLAY_NAME`.
3. **Agent persona name** — the assistant's display name. Default: `DISPLAY_NAME`. Record as `AGENT_NAME`.
Then use `AskUserQuestion` for the agent backend:
4. **Provider** — "Which agent provider should this agent use?" with options `claude` (default, Anthropic Agent SDK) and `opencode` (OpenRouter / Anthropic / etc. via OpenCode — assumes `/add-opencode` has been run). Record as `PROVIDER`.
Then ask in plain text (free-form — model ids are provider-specific strings):
5. **Model** — "Which model id? (Leave blank for the provider default.)" For claude: values like `claude-sonnet-4-5`, `claude-opus-4-5`. For opencode: values like `openrouter/anthropic/claude-sonnet-4`, `opencode/big-pickle`. Record as `MODEL` (may be empty).
## 3. Resolve the DM platform id
This depends on whether the channel supports cold DM via `adapter.openDM`.
@@ -77,10 +85,12 @@ npx tsx scripts/init-first-agent.ts \
--user-id "${CHANNEL}:${USER_HANDLE}" \
--platform-id "${PLATFORM_ID}" \
--display-name "${DISPLAY_NAME}" \
--agent-name "${AGENT_NAME}"
--agent-name "${AGENT_NAME}" \
--provider "${PROVIDER}" \
${MODEL:+--model "${MODEL}"}
```
Add `--welcome "System instruction: ..."` to override the default welcome prompt.
Pass `--provider` even when the user picked the default `claude`, so the DB column is set explicitly rather than left null. Omit `--model` entirely if the user left it blank — the provider will fall back to the host env / SDK default. Add `--welcome "System instruction: ..."` to override the default welcome prompt.
The script:
1. Upserts the `users` row and grants `owner` role if no owner exists.
@@ -140,4 +140,49 @@ export const requestRebuild: McpToolDefinition = {
},
};
export const selfModTools: McpToolDefinition[] = [installPackages, addMcpServer, requestRebuild];
export const setProviderConfig: McpToolDefinition = {
tool: {
name: 'set_provider_config',
description:
'Update YOUR agent group\'s provider config (model, inner provider, base URL, etc.) under `providers.<provider>` in `container.json`. Merges with existing fields; pass `null` for a field to clear it. Requires admin approval; fire-and-forget. The container restarts after approval so the new config takes effect on the next message.',
inputSchema: {
type: 'object' as const,
properties: {
provider: {
type: 'string',
description: 'Provider name — must match a registered provider (e.g. "claude", "opencode").',
},
config: {
type: 'object',
description:
'Fields to merge into `providers.<provider>`. For claude: `{ model: "claude-sonnet-4-5" }`. For opencode: `{ innerProvider, model, smallModel }`. Pass `null` for a field to clear it.',
},
reason: { type: 'string', description: 'Why this change is needed' },
},
required: ['provider', 'config'],
},
},
async handler(args) {
const provider = (args.provider as string | undefined)?.toLowerCase();
const config = args.config as Record<string, unknown> | undefined;
if (!provider) return err('provider is required');
if (!config || typeof config !== 'object') return err('config must be an object');
const requestId = generateId();
writeMessageOut({
id: requestId,
kind: 'system',
content: JSON.stringify({
action: 'set_provider_config',
provider,
config,
reason: (args.reason as string) || '',
}),
});
log(`set_provider_config: ${requestId}${provider} ${JSON.stringify(config)}`);
return ok(`Provider config change submitted. You will be notified when admin approves or rejects.`);
},
};
export const selfModTools: McpToolDefinition[] = [installPackages, addMcpServer, requestRebuild, setProviderConfig];
+38 -5
View File
@@ -16,14 +16,22 @@
* --platform-id discord:@me:1491573333382523708 \
* --display-name "Gavriel" \
* [--agent-name "Andy"] \
* [--welcome "System instruction: ..."]
* [--welcome "System instruction: ..."] \
* [--provider claude|opencode|mock] \
* [--model <provider-specific-id>]
*
* For direct-addressable channels (telegram, whatsapp, etc.), --platform-id
* is typically the same as the handle in --user-id, with the channel prefix.
*
* --provider sets `agent_groups.agent_provider`; --model seeds
* `providers.<provider>.model` in the per-group `container.json`. Both are
* only applied when creating a new group — pre-existing groups keep their
* provider/model settings.
*/
import path from 'path';
import { DATA_DIR } from '../src/config.js';
import { updateContainerConfig } from '../src/container-config.js';
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
import { normalizeName } from '../src/db/agent-destinations.js';
import { initDb } from '../src/db/connection.js';
@@ -47,6 +55,8 @@ interface Args {
displayName: string;
agentName: string;
welcome: string;
provider: string | null;
model: string | null;
}
const DEFAULT_WELCOME =
@@ -82,6 +92,14 @@ function parseArgs(argv: string[]): Args {
out.welcome = val;
i++;
break;
case '--provider':
out.provider = (val ?? '').toLowerCase() || null;
i++;
break;
case '--model':
out.model = val || null;
i++;
break;
}
}
@@ -100,6 +118,8 @@ function parseArgs(argv: string[]): Args {
displayName: out.displayName!,
agentName: out.agentName?.trim() || out.displayName!,
welcome: out.welcome?.trim() || DEFAULT_WELCOME,
provider: out.provider ?? null,
model: out.model ?? null,
};
}
@@ -146,19 +166,21 @@ async function main(): Promise<void> {
// 2. Agent group + filesystem
const folder = `dm-with-${normalizeName(args.displayName)}`;
let ag: AgentGroup | undefined = getAgentGroupByFolder(folder);
if (!ag) {
const existingAg = getAgentGroupByFolder(folder);
let ag: AgentGroup;
if (!existingAg) {
const agId = generateId('ag');
createAgentGroup({
id: agId,
name: args.agentName,
folder,
agent_provider: null,
agent_provider: args.provider,
created_at: now,
});
ag = getAgentGroupByFolder(folder)!;
console.log(`Created agent group: ${ag.id} (${folder})`);
console.log(`Created agent group: ${ag.id} (${folder})${args.provider ? ` [provider: ${args.provider}]` : ''}`);
} else {
ag = existingAg;
console.log(`Reusing agent group: ${ag.id} (${folder})`);
}
initGroupFilesystem(ag, {
@@ -168,6 +190,17 @@ async function main(): Promise<void> {
'When you receive a system welcome prompt, introduce yourself briefly and invite them to chat. Keep replies concise.',
});
// 2b. Seed per-group provider config if a model was specified. Only applied
// on fresh group creation so re-running the script doesn't clobber whatever
// the operator has since tuned in container.json.
if (!existingAg && args.model) {
const provider = (args.provider ?? 'claude').toLowerCase();
updateContainerConfig(ag.folder, (cfg) => {
cfg.providers[provider] = { ...(cfg.providers[provider] ?? {}), model: args.model };
});
console.log(`Seeded providers.${provider}.model = ${args.model}`);
}
// 3. DM messaging group
const platformId = namespacedPlatformId(args.channel, args.platformId);
let mg = getMessagingGroupByPlatform(args.channel, platformId);
+12
View File
@@ -9,8 +9,16 @@
* packages: { apt: string[], npm: string[] }
* imageTag?: string // set by buildAgentGroupImage on rebuild
* additionalMounts?: Array<{hostPath, containerPath, readonly}>
* providers?: { [providerName]: { ... } }
* }
*
* `providers.<name>` is the per-group configuration for a specific agent
* provider (model, base URL, inner provider id, etc.). Each provider's
* host-side registration (`src/providers/<name>.ts`) reads its own block
* via `ctx.providerConfig`. Fields within the block are provider-specific
* and loosely typed (`Record<string, unknown>`) — providers validate their
* own shape at read time.
*
* All fields are optional — a missing file or a partial file both resolve
* to sensible defaults. Writes are atomic-enough (write-then-rename is not
* worth the ceremony here since there's only one writer in practice: the
@@ -38,6 +46,8 @@ export interface ContainerConfig {
packages: { apt: string[]; npm: string[] };
imageTag?: string;
additionalMounts: AdditionalMountConfig[];
/** Per-provider configuration blocks (model, base URL, etc.), keyed by provider name. */
providers: Record<string, Record<string, unknown>>;
}
function emptyConfig(): ContainerConfig {
@@ -45,6 +55,7 @@ function emptyConfig(): ContainerConfig {
mcpServers: {},
packages: { apt: [], npm: [] },
additionalMounts: [],
providers: {},
};
}
@@ -71,6 +82,7 @@ export function readContainerConfig(folder: string): ContainerConfig {
},
imageTag: raw.imageTag,
additionalMounts: raw.additionalMounts ?? [],
providers: raw.providers ?? {},
};
} catch (err) {
console.error(`[container-config] failed to parse ${p}: ${String(err)}`);
+9 -7
View File
@@ -187,13 +187,15 @@ function resolveProviderContribution(
): { provider: string; contribution: ProviderContainerContribution } {
const provider = (session.agent_provider || agentGroup.agent_provider || 'claude').toLowerCase();
const fn = getProviderContainerConfig(provider);
const contribution = fn
? fn({
sessionDir: sessionDir(agentGroup.id, session.id),
agentGroupId: agentGroup.id,
hostEnv: process.env,
})
: {};
if (!fn) return { provider, contribution: {} };
const containerConfig = readContainerConfig(agentGroup.folder);
const contribution = fn({
sessionDir: sessionDir(agentGroup.id, session.id),
agentGroupId: agentGroup.id,
hostEnv: process.env,
providerConfig: containerConfig.providers[provider],
});
return { provider, contribution };
}
+32 -1
View File
@@ -119,7 +119,7 @@ const APPROVAL_OPTIONS: RawOption[] = [
async function requestApproval(
session: Session,
agentName: string,
action: 'install_packages' | 'request_rebuild' | 'add_mcp_server',
action: 'install_packages' | 'request_rebuild' | 'add_mcp_server' | 'set_provider_config',
payload: Record<string, unknown>,
title: string,
question: string,
@@ -865,6 +865,37 @@ async function handleSystemAction(
break;
}
case 'set_provider_config': {
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
notifyAgent(session, 'set_provider_config failed: agent group not found.');
break;
}
const provider = (content.provider as string | undefined)?.toLowerCase();
const config = content.config as Record<string, unknown> | undefined;
const reason = (content.reason as string) || '';
if (!provider) {
notifyAgent(session, 'set_provider_config failed: provider is required.');
break;
}
if (!config || typeof config !== 'object') {
notifyAgent(session, 'set_provider_config failed: config must be an object.');
break;
}
const summary = Object.entries(config)
.map(([k, v]) => `${k}=${v === null ? '(clear)' : JSON.stringify(v)}`)
.join(', ');
await requestApproval(
session,
agentGroup.name,
'set_provider_config',
{ provider, config, reason },
'Set Provider Config',
`Agent "${agentGroup.name}" wants to update ${provider} config:\n${summary}${reason ? `\nReason: ${reason}` : ''}`,
);
break;
}
default:
log.warn('Unknown system action', { action });
}
+35
View File
@@ -320,6 +320,41 @@ async function handleApprovalResponse(
killContainer(session.id, 'mcp server added');
notify(`MCP server "${payload.name}" added. Your container will restart with it on the next message.`);
log.info('MCP server add approved', { approvalId: approval.approval_id, userId });
} else if (approval.action === 'set_provider_config') {
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
notify('set_provider_config approved but agent group missing.');
return;
}
const provider = payload.provider as string;
const config = payload.config as Record<string, unknown>;
updateContainerConfig(agentGroup.folder, (cfg) => {
const merged: Record<string, unknown> = { ...(cfg.providers[provider] ?? {}) };
for (const [key, value] of Object.entries(config)) {
// null/undefined clears the field — anything else is a set.
if (value === null || value === undefined) delete merged[key];
else merged[key] = value;
}
if (Object.keys(merged).length === 0) {
delete cfg.providers[provider];
} else {
cfg.providers[provider] = merged;
}
});
// Kill the container so the next wake picks up the new env from
// resolveProviderContribution.
killContainer(session.id, 'provider config updated');
notify(
`Provider config for "${provider}" updated. Your container will restart with the new settings on the next message.`,
);
log.info('set_provider_config approved', {
approvalId: approval.approval_id,
userId,
provider,
fields: Object.keys(config),
});
}
deletePendingApproval(approval.approval_id);
+36
View File
@@ -0,0 +1,36 @@
/**
* Host-side container config for the `claude` provider.
*
* Claude doesn't need any extra mounts — its session data lives under the
* per-group `.claude-shared` mount (set up in `container-runner.ts::buildMounts`
* for all providers). This file exists purely to emit `ANTHROPIC_MODEL` from
* the per-group config so different agent groups can run different models.
*
* Per-group config (from `groups/<folder>/container.json::providers.claude`):
*
* {
* "providers": {
* "claude": {
* "model": "claude-sonnet-4-5" // → ANTHROPIC_MODEL in the container
* }
* }
* }
*
* Fallback chain: `providers.claude.model` → host `ANTHROPIC_MODEL` → Claude
* Code SDK's built-in default (which tracks the current flagship Sonnet).
*/
import { registerProviderContainerConfig } from './provider-container-registry.js';
function pickString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
registerProviderContainerConfig('claude', (ctx) => {
const cfg = ctx.providerConfig ?? {};
const env: Record<string, string> = {};
const model = pickString(cfg.model) ?? ctx.hostEnv.ANTHROPIC_MODEL;
if (model) env.ANTHROPIC_MODEL = model;
return { env };
});
+2 -2
View File
@@ -1,8 +1,8 @@
// Host-side provider container-config barrel.
// Providers that need host-side container setup (extra mounts, env passthrough,
// per-session directories) self-register on import. Providers with no host
// needs (claude, mock) don't appear here.
// per-session directories, per-group model selection) self-register on import.
//
// Skills add a new provider by appending one import line below.
import './claude.js';
import './opencode.js';
+28 -4
View File
@@ -7,6 +7,19 @@
* (read on the host, injected into the container). NO_PROXY / no_proxy are
* merged with host values so the in-container OpenCode client can talk to
* 127.0.0.1 even when HTTPS_PROXY is set by OneCLI.
*
* Per-group config (from `groups/<folder>/container.json::providers.opencode`)
* takes precedence over the host `.env`:
*
* {
* "providers": {
* "opencode": {
* "innerProvider": "openrouter", // OPENCODE_PROVIDER
* "model": "openrouter/anthropic/claude-sonnet-4", // OPENCODE_MODEL
* "smallModel": "openrouter/anthropic/claude-haiku-4.5" // OPENCODE_SMALL_MODEL
* }
* }
* }
*/
import fs from 'fs';
import path from 'path';
@@ -28,19 +41,30 @@ function mergeNoProxy(current: string | undefined, additions: string): string {
return [...parts].join(',');
}
function pickString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
registerProviderContainerConfig('opencode', (ctx) => {
const opencodeDir = path.join(ctx.sessionDir, 'opencode-xdg');
fs.mkdirSync(opencodeDir, { recursive: true });
const cfg = ctx.providerConfig ?? {};
const env: Record<string, string> = {
XDG_DATA_HOME: '/opencode-xdg',
NO_PROXY: mergeNoProxy(ctx.hostEnv.NO_PROXY, '127.0.0.1,localhost'),
no_proxy: mergeNoProxy(ctx.hostEnv.no_proxy, '127.0.0.1,localhost'),
};
for (const key of ['OPENCODE_PROVIDER', 'OPENCODE_MODEL', 'OPENCODE_SMALL_MODEL'] as const) {
const value = ctx.hostEnv[key];
if (value) env[key] = value;
}
// Per-group config wins, host env is the fallback.
const innerProvider = pickString(cfg.innerProvider) ?? ctx.hostEnv.OPENCODE_PROVIDER;
const model = pickString(cfg.model) ?? ctx.hostEnv.OPENCODE_MODEL;
const smallModel = pickString(cfg.smallModel) ?? ctx.hostEnv.OPENCODE_SMALL_MODEL;
if (innerProvider) env.OPENCODE_PROVIDER = innerProvider;
if (model) env.OPENCODE_MODEL = model;
if (smallModel) env.OPENCODE_SMALL_MODEL = smallModel;
return {
mounts: [{ hostPath: opencodeDir, containerPath: '/opencode-xdg', readonly: false }],
@@ -29,6 +29,13 @@ export interface ProviderContainerContext {
agentGroupId: string;
/** `process.env` at spawn time — pull passthrough values from here. */
hostEnv: NodeJS.ProcessEnv;
/**
* Per-group config block for this provider, read from
* `groups/<folder>/container.json` under `providers.<name>`. Shape is
* provider-specific; providers should treat individual fields as optional
* and fall back to `hostEnv` or sensible defaults.
*/
providerConfig?: Record<string, unknown>;
}
export interface ProviderContainerContribution {