feat(agents): custom context for Tier 2 + periodic system prompt re-injection

- SettingsTab: Custom Context textarea for Tier 2 project cards
- AgentSession passes systemPrompt for ALL projects (Tier 1 gets full
  generated prompt, Tier 2 gets custom context)
- Periodic re-injection: 1-hour timer checks if agent is idle, then
  auto-sends context refresh prompt with role/tools reminder
- AgentPane: autoPrompt prop consumed when session is done/error,
  resumes session with fresh system prompt
This commit is contained in:
DexterFromLab 2026-03-11 15:02:28 +01:00
parent 14808a97e9
commit 0c28f204c7
3 changed files with 79 additions and 9 deletions

View file

@ -58,10 +58,14 @@
agentSystemPrompt?: string;
/** Extra env vars injected into agent process (e.g. BTMSG_AGENT_ID) */
extraEnv?: Record<string, string>;
/** Auto-triggered prompt (e.g. periodic context refresh). Picked up when agent is idle. */
autoPrompt?: string;
/** Called when autoPrompt has been consumed */
onautopromptconsumed?: () => void;
onExit?: () => void;
}
let { sessionId, projectId, prompt: initialPrompt = '', cwd: initialCwd, profile: profileName, provider: providerId = 'claude', capabilities = DEFAULT_CAPABILITIES, useWorktrees = false, agentSystemPrompt, extraEnv, onExit }: Props = $props();
let { sessionId, projectId, prompt: initialPrompt = '', cwd: initialCwd, profile: profileName, provider: providerId = 'claude', capabilities = DEFAULT_CAPABILITIES, useWorktrees = false, agentSystemPrompt, extraEnv, autoPrompt, onautopromptconsumed, onExit }: Props = $props();
let session = $derived(getAgentSession(sessionId));
let inputPrompt = $state(initialPrompt);
@ -145,6 +149,16 @@
// NOTE: Do NOT stop agents in onDestroy — it fires on layout changes/remounts,
// not just explicit close. Stop-on-close is handled by workspace teardown.
// Auto-prompt: pick up externally triggered prompts (e.g. periodic context refresh)
$effect(() => {
if (!autoPrompt || isRunning) return;
// Only trigger if session exists and is idle (done/error)
if (!session || (session.status !== 'done' && session.status !== 'error')) return;
const prompt = autoPrompt;
onautopromptconsumed?.();
startQuery(prompt, true); // resume session with context refresh
});
let promptRef = $state<HTMLTextAreaElement | undefined>();
async function startQuery(text: string, resume = false) {

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import type { ProjectConfig, GroupAgentRole } from '../../types/groups';
import { generateAgentPrompt } from '../../utils/agent-prompts';
import { getActiveGroup } from '../../stores/workspace.svelte';
@ -24,6 +25,9 @@
import { SessionId, ProjectId } from '../../types/ids';
import AgentPane from '../Agent/AgentPane.svelte';
/** How often to re-inject the system prompt (default 1 hour) */
const REINJECTION_INTERVAL_MS = 60 * 60 * 1000;
interface Props {
project: ProjectConfig;
onsessionid?: (id: string) => void;
@ -34,15 +38,19 @@
let providerId = $derived(project.provider ?? getDefaultProviderId());
let providerMeta = $derived(getProvider(providerId));
let group = $derived(getActiveGroup());
// Build system prompt: full agent prompt for Tier 1, custom context for Tier 2
let agentPrompt = $derived.by(() => {
if (!project.isAgent || !project.agentRole || !group) return undefined;
return generateAgentPrompt({
role: project.agentRole as GroupAgentRole,
agentId: project.id,
agentName: project.name,
group,
customPrompt: project.systemPrompt,
});
if (project.isAgent && project.agentRole && group) {
return generateAgentPrompt({
role: project.agentRole as GroupAgentRole,
agentId: project.id,
agentName: project.name,
group,
customPrompt: project.systemPrompt,
});
}
// Tier 2: pass custom context directly (if set)
return project.systemPrompt || undefined;
});
// Inject BTMSG_AGENT_ID for agent projects so they can use btmsg/bttask CLIs
@ -51,6 +59,36 @@
return { BTMSG_AGENT_ID: project.id };
});
// Periodic context re-injection timer
let lastPromptTime = $state(Date.now());
let contextRefreshPrompt = $state<string | undefined>(undefined);
let reinjectionTimer: ReturnType<typeof setInterval> | null = null;
function startReinjectionTimer() {
if (reinjectionTimer) clearInterval(reinjectionTimer);
lastPromptTime = Date.now();
reinjectionTimer = setInterval(() => {
const elapsed = Date.now() - lastPromptTime;
if (elapsed >= REINJECTION_INTERVAL_MS && !contextRefreshPrompt) {
const refreshMsg = project.isAgent
? '[Context Refresh] Review your role and available tools above. Check your inbox with `btmsg inbox` and review the task board with `bttask board`.'
: '[Context Refresh] Review the instructions above and continue your work.';
contextRefreshPrompt = refreshMsg;
}
}, 60_000); // Check every minute
}
function handleAutoPromptConsumed() {
contextRefreshPrompt = undefined;
lastPromptTime = Date.now();
}
// Start timer and clean up
startReinjectionTimer();
onDestroy(() => {
if (reinjectionTimer) clearInterval(reinjectionTimer);
});
let sessionId = $state(SessionId(crypto.randomUUID()));
let lastState = $state<ProjectAgentState | null>(null);
let loading = $state(true);
@ -60,6 +98,8 @@
sessionId = SessionId(crypto.randomUUID());
hasRestoredHistory = false;
lastState = null;
lastPromptTime = Date.now();
contextRefreshPrompt = undefined;
registerSessionProject(sessionId, ProjectId(project.id), providerId);
trackProject(ProjectId(project.id), sessionId);
onsessionid?.(sessionId);
@ -153,6 +193,8 @@
useWorktrees={project.useWorktrees ?? false}
agentSystemPrompt={agentPrompt}
extraEnv={agentEnv}
autoPrompt={contextRefreshPrompt}
onautopromptconsumed={handleAutoPromptConsumed}
onExit={handleNewSession}
/>
{/if}

View file

@ -941,6 +941,20 @@
</div>
</div>
<div class="card-field">
<span class="card-field-label">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
Custom Context
</span>
<textarea
class="agent-prompt-input"
value={project.systemPrompt ?? ''}
placeholder="Additional instructions injected into this project's agent session"
rows="3"
onchange={e => updateProject(activeGroupId, project.id, { systemPrompt: (e.target as HTMLTextAreaElement).value || undefined })}
></textarea>
</div>
<div class="card-footer">
<button class="btn-remove" onclick={() => removeProject(activeGroupId, project.id)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>