Skip to content
Merged
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
22 changes: 22 additions & 0 deletions apps/cli/src/headless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,18 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
process.on('SIGTERM', sigintHandler);

// ─── run ────────────────────────────────────────────────────────────
// SessionStart hook (headless is a one-shot session). Agent-loop hooks
// (UserPromptSubmit/Stop/…) fire from runAgent; SessionEnd fires in finally.
try {
await hooks.dispatch({
event: 'SessionStart',
cwd,
triggeredAt: new Date().toISOString(),
payload: { sessionId: session.id, source: 'headless' },
});
} catch {
/* ignore */
}
let exitCode = 0;
try {
const result = await runAgent({
Expand Down Expand Up @@ -326,6 +338,16 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
}
exitCode = 3;
} finally {
try {
await hooks.dispatch({
event: 'SessionEnd',
cwd,
triggeredAt: new Date().toISOString(),
payload: { sessionId: session.id, exitCode },
});
} catch {
/* ignore */
}
process.off('SIGINT', sigintHandler);
process.off('SIGTERM', sigintHandler);
if (mcpServers.length > 0) await closeAllMcpServers(mcpServers);
Expand Down
24 changes: 24 additions & 0 deletions apps/cli/src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,24 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
}, 2000);
});

// Session-lifecycle hooks (the agent loop fires the per-turn ones).
const fireLifecycle = async (
event: 'SessionStart' | 'SessionEnd' | 'Notification',
payload: Record<string, unknown> = {},
): Promise<void> => {
try {
await hooks.dispatch({
event,
cwd: ctx.cwd,
triggeredAt: new Date().toISOString(),
payload,
});
} catch {
/* hook failure must not break the REPL */
}
};
await fireLifecycle('SessionStart', { sessionId: session.id, source: 'cli' });

while (true) {
let userInput: string;
try {
Expand Down Expand Up @@ -478,8 +496,14 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
if (result.stopReason === 'error') {
output.write(' ✕ Error during agent loop. Try again or /status to inspect.\n\n');
}
// Notification hook — the turn finished and control returns to the user.
await fireLifecycle('Notification', {
message: 'DeepCode finished responding — awaiting your input.',
stopReason: result.stopReason,
});
}

await fireLifecycle('SessionEnd', { sessionId: session.id });
rl.close();
// Clean up MCP server connections
if (mcpServers.length > 0) {
Expand Down
4 changes: 2 additions & 2 deletions docs/BEHAVIOR_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,13 @@ Specific deviations:
| PreToolUse | ✓ | ✓ | ✅ |
| PostToolUse | ✓ | ✓ | ✅ |
| Stop | ✓ | ✓ | ✅ — fires when agent loop ends (any reason) |
| SubagentStop | ✓ | 🔄 | M4+ wiring |
| SubagentStop | ✓ | | ✅ — fires when a Task sub-agent finishes |
| PreCompact | ✓ | ✓ | ✅ — fires through compaction event bus |
| PostCompact | ✓ | ✓ | ✅ |
| SessionStart | ✓ | ✓ | ✅ |
| SessionEnd | ✓ | ✓ | ✅ |
| UserPromptSubmit | ✓ | ✓ | ✅ |
| Notification | ✓ | 🔄 | M8 |
| Notification | ✓ | | ✅ — REPL fires on turn-end (awaiting input) |

## Hook handler types

Expand Down
113 changes: 112 additions & 1 deletion packages/core/src/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runAgent } from './agent.js';
import { HookDispatcher } from './hooks/index.js';
import { SessionManager } from './sessions/index.js';
Expand Down Expand Up @@ -601,4 +601,115 @@ describe('runAgent', () => {
});
expect(result.stopReason).toBe('end_turn');
});

it('fires UserPromptSubmit + Stop lifecycle hooks on a normal run', async () => {
const hooks = new HookDispatcher({ hooks: {} });
const spy = vi.spyOn(hooks, 'dispatch');
await runAgent({
provider: new MockProvider([endTurn('done')]),
tools: new ToolRegistry(),
systemPrompt: '',
userMessage: 'hi',
model: 'deepseek-chat',
cwd,
hooks,
});
const events = spy.mock.calls.map((c) => (c[0] as { event: string }).event);
expect(events).toContain('UserPromptSubmit');
expect(events).toContain('Stop');
});

it('UserPromptSubmit hook injects additionalContext into the prompt', async () => {
const hooks = new HookDispatcher({
hooks: {
UserPromptSubmit: [{ hooks: [{ type: 'prompt', prompt: 'REMEMBER: be terse.' }] }],
},
});
const provider = new MockProvider([endTurn('ok')]);
await runAgent({
provider,
tools: new ToolRegistry(),
systemPrompt: '',
userMessage: 'do the thing',
model: 'deepseek-chat',
cwd,
hooks,
systemReminders: false,
});
const firstUser = provider.received[0]!.messages[0] as StoredMessage;
const text = firstUser.content[0];
expect(text?.type).toBe('text');
if (text?.type === 'text') {
expect(text.text).toContain('do the thing');
expect(text.text).toContain('REMEMBER: be terse.');
}
});

it('fires SubagentStop when a Task sub-agent finishes', async () => {
const hooks = new HookDispatcher({ hooks: {} });
const spy = vi.spyOn(hooks, 'dispatch');
await runAgent({
provider: new MockProvider([
toolUse('delegating', {
type: 'tool_use',
id: 't1',
name: 'Task',
input: { prompt: 'explore' },
}),
endTurn('sub done'), // sub-agent run
endTurn('top done'), // back at top level
]),
tools: new ToolRegistry(),
systemPrompt: '',
userMessage: 'delegate',
model: 'deepseek-chat',
cwd,
hooks,
});
const events = spy.mock.calls.map((c) => (c[0] as { event: string }).event);
expect(events).toContain('SubagentStop');
});

it('fires PreCompact + PostCompact around auto-compaction', async () => {
await fs.writeFile(join(cwd, 'x.txt'), 'data');
const hooks = new HookDispatcher({ hooks: {} });
const spy = vi.spyOn(hooks, 'dispatch');
const scripted: ProviderResult[] = [
{
content: withToolCall('working', {
type: 'tool_use',
id: 'big',
name: 'Read',
input: { file_path: 'x.txt' },
}),
stopReason: 'tool_use',
usage: { inputTokens: 90, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0 },
},
endTurn('done'),
];
const provider: Provider = {
name: 'counting',
async runTurn(o: ProviderRunOpts): Promise<ProviderResult> {
if (o.systemPrompt.startsWith('You compress long agent conversations')) {
return endTurn('summary');
}
const next = scripted.shift();
if (!next) throw new Error('no scripted response');
return next;
},
};
await runAgent({
provider,
tools: new ToolRegistry(),
systemPrompt: 'agent',
userMessage: 'go',
model: 'deepseek-chat',
cwd,
hooks,
autoCompact: { contextWindow: 100, threshold: 0.8, keepFirstPairs: 0, keepLastMessages: 1 },
});
const events = spy.mock.calls.map((c) => (c[0] as { event: string }).event);
expect(events).toContain('PreCompact');
expect(events).toContain('PostCompact');
});
});
64 changes: 64 additions & 0 deletions packages/core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,23 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
/* reminder failures must not abort the agent */
}
}
// UserPromptSubmit hook — fires before the prompt is processed; any JSON
// `additionalContext` it returns is appended to the prompt. Top-level only
// (a sub-agent's prompt isn't a user prompt).
if (opts.hooks && (opts.subAgentDepth ?? 0) === 0) {
try {
const r = await opts.hooks.dispatch({
event: 'UserPromptSubmit',
cwd: opts.cwd,
triggeredAt: new Date().toISOString(),
payload: { prompt: opts.userMessage },
});
const extra = r.json?.additionalContext;
if (typeof extra === 'string' && extra.trim()) userText = `${userText}\n\n${extra}`;
} catch {
/* hook failure must not abort the prompt */
}
}
const userMsg: StoredMessage = {
role: 'user',
content: [{ type: 'text', text: userText }],
Expand Down Expand Up @@ -264,6 +281,19 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
.map((b) => b.text)
.join('\n')
.trim();
// SubagentStop hook — fires when a sub-agent finishes.
if (opts.hooks) {
try {
await opts.hooks.dispatch({
event: 'SubagentStop',
cwd: opts.cwd,
triggeredAt: new Date().toISOString(),
payload: { agentType: agentType ?? 'general', turnsUsed: sub.turnsUsed },
});
} catch {
/* hook failure must not break the sub-agent result */
}
}
return { text, turnsUsed: sub.turnsUsed, agentType: agentType ?? 'general' };
};
}
Expand Down Expand Up @@ -323,6 +353,22 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
const totalUsage = { inputTokens: 0, outputTokens: 0, reasoningTokens: 0 };
let turnsUsed = 0;

// Stop hook — fires when the TOP-LEVEL agent finishes a run (a sub-agent's
// completion is signalled by SubagentStop instead). Observation only.
const fireStop = async (reason: string): Promise<void> => {
if (!opts.hooks || depth !== 0) return;
try {
await opts.hooks.dispatch({
event: 'Stop',
cwd: opts.cwd,
triggeredAt: new Date().toISOString(),
payload: { stopReason: reason, turnsUsed },
});
} catch {
/* hook failure must not affect the result */
}
};

for (let turn = 0; turn < maxTurns; turn++) {
if (opts.signal?.aborted) {
return {
Expand Down Expand Up @@ -392,6 +438,7 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {

// If no tool calls, we're done
if (result.stopReason !== 'tool_use') {
await fireStop('end_turn');
return { history, turnsUsed, usage: totalUsage, stopReason: 'end_turn', modeSignal };
}

Expand Down Expand Up @@ -580,6 +627,14 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
})
) {
try {
if (opts.hooks) {
await opts.hooks.dispatch({
event: 'PreCompact',
cwd: opts.cwd,
triggeredAt: new Date().toISOString(),
payload: { messages: history.length, trigger: 'auto' },
});
}
const compactResult = await compact(history, {
provider: opts.provider,
summarizerModel: opts.autoCompact.summarizerModel,
Expand All @@ -595,12 +650,21 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
outputTokens: compactResult.usage.outputTokens,
reasoningTokens: 0,
});
if (opts.hooks) {
await opts.hooks.dispatch({
event: 'PostCompact',
cwd: opts.cwd,
triggeredAt: new Date().toISOString(),
payload: { messages: history.length, trigger: 'auto' },
});
}
} catch {
// compaction failure is non-fatal — continue with full history
}
}
}

await fireStop('max_turns');
return { history, turnsUsed, usage: totalUsage, stopReason: 'max_turns', modeSignal };
}

Expand Down
Loading