diff --git a/cloudflare-gastown/container/src/agent-runner.ts b/cloudflare-gastown/container/src/agent-runner.ts index f96520977..a5e802756 100644 --- a/cloudflare-gastown/container/src/agent-runner.ts +++ b/cloudflare-gastown/container/src/agent-runner.ts @@ -416,7 +416,7 @@ async function createMayorWorkspace(rigId: string): Promise { * user customization), the TownDO sends the updated prompt and we * rewrite this file. */ -async function writeMayorSystemPromptToAgentsMd( +export async function writeMayorSystemPromptToAgentsMd( workspaceDir: string, systemPrompt: string ): Promise { diff --git a/cloudflare-gastown/container/src/control-server.ts b/cloudflare-gastown/container/src/control-server.ts index 70ba58c62..8fc7f73e5 100644 --- a/cloudflare-gastown/container/src/control-server.ts +++ b/cloudflare-gastown/container/src/control-server.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; import { z } from 'zod'; -import { runAgent, resolveGitCredentials } from './agent-runner'; +import { runAgent, resolveGitCredentials, writeMayorSystemPromptToAgentsMd } from './agent-runner'; import { stopAgent, sendMessage, @@ -263,6 +263,29 @@ app.patch('/agents/:agentId/model', async c => { return c.json({ updated: true }); }); +// PUT /agents/:agentId/system-prompt +// Rewrite the mayor's AGENTS.md with an updated system prompt. +// Used when custom instructions change so the running mayor picks them up +// on its next session restart without a full container restart. +app.put('/agents/:agentId/system-prompt', async c => { + const { agentId } = c.req.param(); + const agent = getAgentStatus(agentId); + if (!agent) { + return c.json({ error: `Agent ${agentId} not found` }, 404); + } + const body: unknown = await c.req.json().catch(() => null); + if ( + !body || + typeof body !== 'object' || + !('systemPrompt' in body) || + typeof body.systemPrompt !== 'string' + ) { + return c.json({ error: 'Missing or invalid systemPrompt field' }, 400); + } + await writeMayorSystemPromptToAgentsMd(agent.workdir, body.systemPrompt); + return c.json({ updated: true }); +}); + // GET /agents/:agentId/status app.get('/agents/:agentId/status', c => { const { agentId } = c.req.param(); diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index b3eeaa158..a035caf24 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -2088,6 +2088,49 @@ export class TownDO extends DurableObject { // model from the updated town config automatically. } + /** + * Rewrite the running mayor's AGENTS.md with the current system prompt + * (including custom instructions). Called when custom instructions change + * so the mayor picks them up on its next session restart. + */ + async updateMayorSystemPrompt(): Promise { + const townId = this.townId; + const mayor = agents.listAgents(this.sql, { role: 'mayor' })[0]; + if (!mayor) return; + + const containerStatus = await dispatch.checkAgentContainerStatus(this.env, townId, mayor.id); + const isAlive = containerStatus.status === 'running' || containerStatus.status === 'starting'; + if (!isAlive) return; + + const townConfig = await this.getTownConfig(); + const systemPrompt = dispatch.appendCustomInstructions( + dispatch.systemPromptForRole({ + role: 'mayor', + identity: mayor.identity, + agentName: 'mayor', + rigId: `mayor-${townId}`, + townId, + gates: townConfig.refinery?.gates ?? [], + }), + 'mayor', + townConfig + ); + + const updated = await dispatch.updateMayorSystemPromptInContainer( + this.env, + townId, + mayor.id, + systemPrompt + ); + if (updated) { + console.log(`${TOWN_LOG} updateMayorSystemPrompt: rewrote AGENTS.md for mayor ${mayor.id}`); + } else { + console.warn( + `${TOWN_LOG} updateMayorSystemPrompt: failed to rewrite AGENTS.md for mayor ${mayor.id}` + ); + } + } + async getMayorStatus(): Promise<{ configured: boolean; townId: string; diff --git a/cloudflare-gastown/src/dos/town/config.ts b/cloudflare-gastown/src/dos/town/config.ts index 0d064ed6b..3d2441a0b 100644 --- a/cloudflare-gastown/src/dos/town/config.ts +++ b/cloudflare-gastown/src/dos/town/config.ts @@ -91,6 +91,23 @@ export async function updateTownConfig( update.container.sleep_after_minutes ?? current.container?.sleep_after_minutes, } : current.container, + custom_instructions: + update.custom_instructions !== undefined + ? { + polecat: + 'polecat' in update.custom_instructions + ? update.custom_instructions.polecat + : current.custom_instructions?.polecat, + refinery: + 'refinery' in update.custom_instructions + ? update.custom_instructions.refinery + : current.custom_instructions?.refinery, + mayor: + 'mayor' in update.custom_instructions + ? update.custom_instructions.mayor + : current.custom_instructions?.mayor, + } + : current.custom_instructions, }; const validated = TownConfigSchema.parse(merged); diff --git a/cloudflare-gastown/src/dos/town/container-dispatch.ts b/cloudflare-gastown/src/dos/town/container-dispatch.ts index 4932e92a5..2a671d979 100644 --- a/cloudflare-gastown/src/dos/town/container-dispatch.ts +++ b/cloudflare-gastown/src/dos/town/container-dispatch.ts @@ -241,6 +241,21 @@ export function systemPromptForRole(params: { } } +/** + * Append per-role custom instructions from town config to a system prompt. + * Returns the prompt unchanged when no custom instructions exist for the role. + */ +export function appendCustomInstructions( + systemPrompt: string, + role: string, + townConfig: TownConfig +): string { + const roleKey = role as keyof NonNullable; + const instructions = townConfig.custom_instructions?.[roleKey]?.trim(); + if (!instructions) return systemPrompt; + return `${systemPrompt}\n\n## Custom Instructions (from town settings)\n\n${instructions}`; +} + /** Generate a branch name for an agent working on a specific bead. */ export function branchForAgent(name: string, beadId?: string): string { const slug = name @@ -412,16 +427,19 @@ export async function startAgentInContainer( }), model: resolveModel(params.townConfig, params.rigId, params.role), smallModel: resolveSmallModel(params.townConfig), - systemPrompt: + systemPrompt: appendCustomInstructions( params.systemPromptOverride ?? - systemPromptForRole({ - role: params.role, - identity: params.identity, - agentName: params.agentName, - rigId: params.rigId, - townId: params.townId, - gates: params.townConfig.refinery?.gates ?? [], - }), + systemPromptForRole({ + role: params.role, + identity: params.identity, + agentName: params.agentName, + rigId: params.rigId, + townId: params.townId, + gates: params.townConfig.refinery?.gates ?? [], + }), + params.role, + params.townConfig + ), gitUrl: params.gitUrl, branch: params.convoyFeatureBranch ? branchForConvoyAgent(params.convoyFeatureBranch, params.agentName, params.beadId) @@ -669,6 +687,29 @@ export async function sendMessageToAgent( * Hot-update the model for a running agent without restarting the session. * Best-effort — returns false if the container is down or the agent is not running. */ +/** + * Rewrite the mayor's AGENTS.md with an updated system prompt. + * Called when custom instructions change so the running mayor picks them up. + */ +export async function updateMayorSystemPromptInContainer( + env: Env, + townId: string, + agentId: string, + systemPrompt: string +): Promise { + try { + const container = getTownContainerStub(env, townId); + const response = await container.fetch(`http://container/agents/${agentId}/system-prompt`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ systemPrompt }), + }); + return response.ok; + } catch { + return false; + } +} + export async function updateAgentModelInContainer( env: Env, townId: string, diff --git a/cloudflare-gastown/src/handlers/town-config.handler.ts b/cloudflare-gastown/src/handlers/town-config.handler.ts index de0929aba..f0a48135b 100644 --- a/cloudflare-gastown/src/handlers/town-config.handler.ts +++ b/cloudflare-gastown/src/handlers/town-config.handler.ts @@ -40,7 +40,18 @@ export async function handleUpdateTownConfig(c: Context, params: { t } const townDO = getTownDOStub(c.env, params.townId); + const existingConfig = await townDO.getTownConfig(); const config = await townDO.updateTownConfig(parsed.data); + + // Rewrite the mayor's AGENTS.md when custom instructions change + if (config.custom_instructions?.mayor !== existingConfig.custom_instructions?.mayor) { + try { + await townDO.updateMayorSystemPrompt(); + } catch (err) { + console.warn(`${LOG} handleUpdateTownConfig: updateMayorSystemPrompt failed:`, err); + } + } + console.log(`${LOG} handleUpdateTownConfig: town=${params.townId} updated config`); return c.json(resSuccess(maskSensitiveValues(config))); } diff --git a/cloudflare-gastown/src/trpc/router.ts b/cloudflare-gastown/src/trpc/router.ts index 8169c0d7c..e5e26287d 100644 --- a/cloudflare-gastown/src/trpc/router.ts +++ b/cloudflare-gastown/src/trpc/router.ts @@ -1003,6 +1003,18 @@ export const gastownRouter = router({ } } + // Rewrite the mayor's AGENTS.md when custom instructions change so the + // running mayor picks them up on its next session restart. + const mayorInstructionsChanged = + result.custom_instructions?.mayor !== existingConfig.custom_instructions?.mayor; + if (mayorInstructionsChanged) { + try { + await townStub.updateMayorSystemPrompt(); + } catch (err) { + console.warn('[gastown-trpc] updateTownConfig: updateMayorSystemPrompt failed:', err); + } + } + return result; }), diff --git a/cloudflare-gastown/src/types.ts b/cloudflare-gastown/src/types.ts index f59b3d216..455f58ac4 100644 --- a/cloudflare-gastown/src/types.ts +++ b/cloudflare-gastown/src/types.ts @@ -291,6 +291,15 @@ export const TownConfigSchema = z.object({ /** When true, AI agent co-authorship trailer is omitted from commits. * Only takes effect when git_author_name is set. */ disable_ai_coauthor: z.boolean().default(false), + + /** Per-role custom instructions appended to the agent's system prompt. */ + custom_instructions: z + .object({ + polecat: z.string().max(2000).optional(), + refinery: z.string().max(2000).optional(), + mayor: z.string().max(2000).optional(), + }) + .optional(), }); export type TownConfig = z.infer; @@ -347,6 +356,13 @@ export const TownConfigUpdateSchema = z.object({ git_author_name: z.string().optional(), git_author_email: z.string().optional(), disable_ai_coauthor: z.boolean().optional(), + custom_instructions: z + .object({ + polecat: z.string().max(2000).optional(), + refinery: z.string().max(2000).optional(), + mayor: z.string().max(2000).optional(), + }) + .optional(), }); export type TownConfigUpdate = z.infer; diff --git a/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx b/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx index 19fe2fade..d3e5b2556 100644 --- a/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx +++ b/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx @@ -29,6 +29,7 @@ import { Container, User, Key, + MessageSquareText, X, } from 'lucide-react'; import { @@ -38,6 +39,7 @@ import { AccordionContent, } from '@/components/ui/accordion'; import { Slider } from '@/components/ui/slider'; +import { Textarea } from '@/components/ui/textarea'; import { motion } from 'motion/react'; import { AdminViewingBanner } from '@/components/gastown/AdminViewingBanner'; import { useRouter } from 'next/navigation'; @@ -69,6 +71,7 @@ const SECTIONS = [ { id: 'merge-strategy', label: 'Merge Strategy', icon: GitPullRequest }, { id: 'refinery', label: 'Refinery', icon: Shield }, { id: 'container', label: 'Container', icon: Container }, + { id: 'custom-instructions', label: 'Custom Instructions', icon: MessageSquareText }, { id: 'danger-zone', label: 'Danger Zone', icon: Trash2 }, ] as const; @@ -256,6 +259,9 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI const [gitAuthorName, setGitAuthorName] = useState(''); const [gitAuthorEmail, setGitAuthorEmail] = useState(''); const [disableAiCoauthor, setDisableAiCoauthor] = useState(false); + const [polecatInstructions, setPolecatInstructions] = useState(''); + const [refineryInstructions, setRefineryInstructions] = useState(''); + const [mayorInstructions, setMayorInstructions] = useState(''); const [initialized, setInitialized] = useState(false); const [showTokens, setShowTokens] = useState(false); @@ -285,6 +291,9 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI setGitAuthorName(cfg.git_author_name ?? ''); setGitAuthorEmail(cfg.git_author_email ?? ''); setDisableAiCoauthor(cfg.disable_ai_coauthor ?? false); + setPolecatInstructions(cfg.custom_instructions?.polecat ?? ''); + setRefineryInstructions(cfg.custom_instructions?.refinery ?? ''); + setMayorInstructions(cfg.custom_instructions?.mayor ?? ''); setInitialized(true); } @@ -330,6 +339,11 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI auto_merge: autoMerge, require_clean_merge: true, }, + custom_instructions: { + polecat: polecatInstructions || undefined, + refinery: refineryInstructions || undefined, + mayor: mayorInstructions || undefined, + }, }, }); } @@ -829,13 +843,47 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI + {/* ── Custom Instructions ────────────────────────────────── */} + +
+ {( + [ + ['Polecat Instructions', polecatInstructions, setPolecatInstructions], + ['Refinery Instructions', refineryInstructions, setRefineryInstructions], + ['Mayor Instructions', mayorInstructions, setMayorInstructions], + ] as const + ).map(([roleLabel, value, setValue]) => ( + +
+