Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cloudflare-gastown/container/src/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ async function createMayorWorkspace(rigId: string): Promise<string> {
* 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<void> {
Expand Down
25 changes: 24 additions & 1 deletion cloudflare-gastown/container/src/control-server.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
Expand Down
43 changes: 43 additions & 0 deletions cloudflare-gastown/src/dos/Town.do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2088,6 +2088,49 @@ export class TownDO extends DurableObject<Env> {
// 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<void> {
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;
Expand Down
17 changes: 17 additions & 0 deletions cloudflare-gastown/src/dos/town/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
59 changes: 50 additions & 9 deletions cloudflare-gastown/src/dos/town/container-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TownConfig['custom_instructions']>;
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<boolean> {
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,
Expand Down
11 changes: 11 additions & 0 deletions cloudflare-gastown/src/handlers/town-config.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,18 @@ export async function handleUpdateTownConfig(c: Context<GastownEnv>, 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)));
}
Expand Down
12 changes: 12 additions & 0 deletions cloudflare-gastown/src/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}),

Expand Down
16 changes: 16 additions & 0 deletions cloudflare-gastown/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof TownConfigSchema>;
Expand Down Expand Up @@ -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<typeof TownConfigUpdateSchema>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
Container,
User,
Key,
MessageSquareText,
X,
} from 'lucide-react';
import {
Expand All @@ -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';
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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,
},
},
});
}
Expand Down Expand Up @@ -829,13 +843,47 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI
</div>
</SettingsSection>

{/* ── Custom Instructions ────────────────────────────────── */}
<SettingsSection
id="custom-instructions"
title="Custom Instructions"
description="Customize the system prompt for each agent role. These instructions are appended to the default prompt and apply to all agents of that role."
icon={MessageSquareText}
index={9}
>
<div className="space-y-5">
{(
[
['Polecat Instructions', polecatInstructions, setPolecatInstructions],
['Refinery Instructions', refineryInstructions, setRefineryInstructions],
['Mayor Instructions', mayorInstructions, setMayorInstructions],
] as const
).map(([roleLabel, value, setValue]) => (
<FieldGroup key={roleLabel} label={roleLabel}>
<div className="relative">
<Textarea
value={value}
onChange={e => setValue(e.target.value.slice(0, 2000))}
placeholder={`Custom instructions for ${roleLabel.replace(' Instructions', '').toLowerCase()} agents…`}
rows={4}
className="border-white/[0.08] bg-white/[0.03] text-sm text-white/85 placeholder:text-white/20"
/>
<span className="absolute right-2 bottom-2 text-[10px] text-white/20">
{value.length} / 2000
</span>
</div>
</FieldGroup>
))}
</div>
</SettingsSection>

{/* ── Danger Zone ──────────────────────────────────────── */}
<SettingsSection
id="danger-zone"
title="Danger Zone"
description="Irreversible actions for this town."
icon={Trash2}
index={9}
index={10}
>
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border border-red-500/20 bg-red-500/5 px-4 py-3">
Expand Down
2 changes: 1 addition & 1 deletion src/components/gastown/AgentDetailDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function AgentDetailDrawer({
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 z-50 bg-black/60" />
<Drawer.Content
className="fixed top-0 right-0 bottom-0 z-50 flex w-[480px] max-w-[94vw] flex-col outline-none"
className="fixed top-0 right-0 bottom-0 z-50 flex w-[600px] max-w-[94vw] flex-col outline-none"
style={{ '--initial-transform': 'calc(100% + 8px)' } as React.CSSProperties}
>
<div className="flex h-full flex-col overflow-hidden rounded-l-2xl border-l border-white/[0.08] bg-[oklch(0.12_0_0)]">
Expand Down
Loading
Loading