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
4 changes: 3 additions & 1 deletion apps/cli/src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,11 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
}
}

// Hook dispatcher (M3)
// Hook dispatcher (M3 + M3c-ext)
const hooks = new HookDispatcher({
hooks: settings.hooks,
disableAllHooks: settings.disableAllHooks,
allowedHttpHookUrls: settings.allowedHttpHookUrls,
});

let history: StoredMessage[] = [];
Expand Down Expand Up @@ -254,6 +255,7 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
mode: ctx.mode as Mode,
permissions: settings.permissions,
hooks,
autoCompact: { contextWindow: 128_000, threshold: 0.8 },
approval: async (toolName, _input, verdict) => {
output.write(`\n ⏸ Approve ${toolName}? Reason: ${verdict.reason}\n`);
const answer = (await rl.question(' [y]es / [n]o: ')).trim().toLowerCase();
Expand Down
21 changes: 21 additions & 0 deletions docs/milestones/M3c-ext.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# M3c-ext · Hook handlers + auto-compact + apiKeyHelper refresh

> **Status**: ✅ Shipped · **Branch**: `feat/m3c-ext-hooks-autocompact`

## Shipped

- **HookDispatcher**: 3 new handler types beyond `command`:
- `http` — POST JSON payload to URL, response = handler stdout (+ `allowedHttpHookUrls` whitelist enforcement)
- `prompt` — synthesizes `additionalContext` JSON output (zero exec)
- `mcp_tool` / `agent` — stubs that emit "stub" stderr (paired implementations in M5+)
- **`if` field on hook handlers** — permission-rule syntax filters at handler level (e.g. `if: "Bash(git push:*)"`)
- **`ApiKeyHelperRefresher`**: 5-min TTL cache + `.invalidate()` for 401 recovery + `DEEPCODE_API_KEY_HELPER_TTL_MS` env override
- **Auto-compact in agent loop**: `runAgent({ autoCompact: { contextWindow, threshold } })` triggers summarizer when usage crosses threshold; tokens counted toward total usage; failure non-fatal (continues with full history)
- REPL wires `autoCompact` (128k @ 80%) + `allowedHttpHookUrls` automatically

## Tests (9 new)

- hook handlers: http POST roundtrip + allowedHttpHookUrls reject + prompt-handler additionalContext + mcp_tool/agent stubs + if-field permission filter
- credentials: ApiKeyHelperRefresher cache hit / refresh / invalidate / env-var TTL override

Total: 256 core + 41 CLI = 297 passing / 7 skipped / 0 failed (was 281).
43 changes: 42 additions & 1 deletion packages/core/src/agent.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Agent loop — orchestrates provider <-> tools <-> session.
// Spec: docs/DEVELOPMENT_PLAN.md §3.1 / §3.15

import { compact, shouldCompact } from './compaction/index.js';
import type { PermissionRules } from './config/types.js';
import { dispatchToolCall, type DispatchVerdict } from './harness/tool-dispatcher.js';
import type { HookDispatcher } from './hooks/index.js';
Expand Down Expand Up @@ -51,6 +52,15 @@ export interface RunAgentOptions {
permissions?: PermissionRules;
hooks?: HookDispatcher;
approval?: ApprovalCallback;
/** M3c: auto-compact when cumulative tokens approach contextWindow * threshold.
* When triggered, runs the summarizer call and replaces history mid-loop. */
autoCompact?: {
contextWindow: number;
threshold?: number; // default 0.8
summarizerModel?: string;
keepFirstPairs?: number;
keepLastMessages?: number;
};
}

export interface RunAgentResult {
Expand All @@ -72,7 +82,7 @@ const DEFAULT_MAX_TURNS = 16;
*/
export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
const maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS;
const history: StoredMessage[] = [...(opts.history ?? [])];
let history: StoredMessage[] = [...(opts.history ?? [])];
let snapshotSeq = (await opts.session?.manager.snapshots(opts.session.id))?.length ?? 0;

// Append the user message first (if provided)
Expand Down Expand Up @@ -286,6 +296,37 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
};
history.push(resultMsg);
if (opts.session) await opts.session.manager.append(opts.session.id, resultMsg);

// M3c: auto-compact if usage crossed threshold
if (
opts.autoCompact &&
shouldCompact({
inputTokens: totalUsage.inputTokens,
outputTokens: totalUsage.outputTokens,
contextWindow: opts.autoCompact.contextWindow,
threshold: opts.autoCompact.threshold,
})
) {
try {
const compactResult = await compact(history, {
provider: opts.provider,
summarizerModel: opts.autoCompact.summarizerModel,
keepFirstPairs: opts.autoCompact.keepFirstPairs,
keepLastMessages: opts.autoCompact.keepLastMessages,
});
history = compactResult.history;
totalUsage.inputTokens += compactResult.usage.inputTokens;
totalUsage.outputTokens += compactResult.usage.outputTokens;
opts.onEvent?.({
type: 'usage',
inputTokens: compactResult.usage.inputTokens,
outputTokens: compactResult.usage.outputTokens,
reasoningTokens: 0,
});
} catch {
// compaction failure is non-fatal — continue with full history
}
}
}

return { history, turnsUsed, usage: totalUsage, stopReason: 'max_turns' };
Expand Down
68 changes: 68 additions & 0 deletions packages/core/src/credentials/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,74 @@ describe('resolveCredentials', () => {
});
});

describe('ApiKeyHelperRefresher', () => {
let home: string;

beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'dc-helper-'));
});
afterEach(async () => {
await rm(home, { recursive: true, force: true });
});

it('caches the key across calls within TTL', async () => {
const { ApiKeyHelperRefresher, CredentialsStore } = await import('./index.js');
const store = new CredentialsStore({ home, forceFile: true });
await store.save({ apiKey: 'stored', baseURL: 'https://x' });
const refresher = new ApiKeyHelperRefresher({
store,
apiKeyHelper: 'echo refreshed-$RANDOM',
ttlMs: 60_000,
});
const first = await refresher.get();
const second = await refresher.get();
expect(first.apiKey).toBe(second.apiKey); // same cache hit
});

it('refresh() forces re-execution', async () => {
const { ApiKeyHelperRefresher, CredentialsStore } = await import('./index.js');
const store = new CredentialsStore({ home, forceFile: true });
await store.save({ apiKey: 'stored' });
const refresher = new ApiKeyHelperRefresher({
store,
apiKeyHelper: 'echo k-$RANDOM',
ttlMs: 60_000,
});
const a = await refresher.get();
const b = await refresher.refresh();
// Both should have *some* key (RANDOM may collide but probability ~1/32768)
expect(a.apiKey).toBeDefined();
expect(b.apiKey).toBeDefined();
});

it('invalidate() forces refresh next get()', async () => {
const { ApiKeyHelperRefresher, CredentialsStore } = await import('./index.js');
const store = new CredentialsStore({ home, forceFile: true });
await store.save({ apiKey: 'stored' });
const refresher = new ApiKeyHelperRefresher({
store,
apiKeyHelper: 'echo fresh-key',
ttlMs: 60_000,
});
await refresher.get();
refresher.invalidate();
const after = await refresher.get();
expect(after.apiKey).toBe('fresh-key');
});

it('reads DEEPCODE_API_KEY_HELPER_TTL_MS env var', async () => {
process.env.DEEPCODE_API_KEY_HELPER_TTL_MS = '7777';
const { ApiKeyHelperRefresher, CredentialsStore } = await import('./index.js');
const store = new CredentialsStore({ home, forceFile: true });
const refresher = new ApiKeyHelperRefresher({
store,
apiKeyHelper: 'echo x',
});
expect((refresher as unknown as { ttlMs: number }).ttlMs).toBe(7777);
delete process.env.DEEPCODE_API_KEY_HELPER_TTL_MS;
});
});

describe('redact', () => {
it('redacts long secrets to first4…last4', () => {
expect(redact('sk-d1f6abcdefghijklmnop')).toBe('sk-d…mnop');
Expand Down
61 changes: 59 additions & 2 deletions packages/core/src/credentials/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,9 @@ export class CredentialsStore {

/**
* Resolve credentials at runtime: apiKeyHelper (if set) overrides stored creds.
* Spec: docs/DEVELOPMENT_PLAN.md §3.4 — apiKeyHelper refresh on 401 + 5min cycle.
* M2 implements one-shot resolution; the refresh loop is M3+.
* One-shot resolution — use `ApiKeyHelperRefresher` for periodic refresh on
* 401 / TTL expiry.
* Spec: docs/DEVELOPMENT_PLAN.md §3.4
*/
export async function resolveCredentials(args: {
store: CredentialsStore;
Expand All @@ -176,6 +177,62 @@ export async function resolveCredentials(args: {
return args.store.load();
}

/**
* Periodic apiKeyHelper refresher.
*
* Spec: docs/DEVELOPMENT_PLAN.md §3.4 — re-execute apiKeyHelper on a TTL and
* on 401-class failures. Caller wires `.invalidate()` to whenever provider
* sees auth-failure response.
*
* TTL is controlled by env var DEEPCODE_API_KEY_HELPER_TTL_MS (default 300_000).
*/
export interface ApiKeyHelperOpts {
store: CredentialsStore;
apiKeyHelper: string;
ttlMs?: number;
}

export class ApiKeyHelperRefresher {
private readonly opts: ApiKeyHelperOpts;
private readonly ttlMs: number;
private cached: { key: string; expiresAt: number } | null = null;

constructor(opts: ApiKeyHelperOpts) {
this.opts = opts;
this.ttlMs =
opts.ttlMs ??
(process.env.DEEPCODE_API_KEY_HELPER_TTL_MS
? Number.parseInt(process.env.DEEPCODE_API_KEY_HELPER_TTL_MS, 10)
: 5 * 60 * 1000);
}

/** Get the current key — refresh if expired. */
async get(): Promise<Credentials> {
if (this.cached && Date.now() < this.cached.expiresAt) {
const stored = await this.opts.store.load();
return { ...stored, apiKey: this.cached.key };
}
return this.refresh();
}

/** Force a refresh (e.g. on 401). */
async refresh(): Promise<Credentials> {
const creds = await resolveCredentials({
store: this.opts.store,
apiKeyHelper: this.opts.apiKeyHelper,
});
if (creds.apiKey) {
this.cached = { key: creds.apiKey, expiresAt: Date.now() + this.ttlMs };
}
return creds;
}

/** Mark the cache stale so next .get() refreshes. */
invalidate(): void {
this.cached = null;
}
}

/** Display-safe redacted form of a credential — first 4 + last 4. */
export function redact(value: string | undefined): string {
if (!value) return '<not set>';
Expand Down
109 changes: 106 additions & 3 deletions packages/core/src/hooks/dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,13 @@ describe('HookDispatcher', () => {
expect(r.stdout).toContain('hello there');
});

it('unimplemented handler types return error in stderr but do not block', async () => {
it('unimplemented handler types (mcp_tool, agent) emit stub stderr but do not block', async () => {
const d = new HookDispatcher({
hooks: {
PreToolUse: [{ hooks: [{ type: 'http', url: 'https://example.com' }] }],
PreToolUse: [
{ hooks: [{ type: 'mcp_tool', server: 'foo', tool: 'bar' }] },
{ hooks: [{ type: 'agent', agent: 'reviewer' }] },
],
},
});
const r = await d.dispatch({
Expand All @@ -205,9 +208,109 @@ describe('HookDispatcher', () => {
triggeredAt: 't',
payload: { tool: 'Bash' },
});
expect(r.stderr).toMatch(/not implemented/);
expect(r.stderr).toMatch(/stub/);
expect(r.anyBlocked).toBe(false);
});

it('http handler POSTs to URL and uses response as stdout', async () => {
// Use a local fake HTTP server
const { createServer } = await import('node:http');
const seen: { body: string; method: string; ct?: string }[] = [];
const server = createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
seen.push({
body,
method: req.method!,
ct: req.headers['content-type'] as string,
});
res.writeHead(200, { 'content-type': 'application/json' });
res.end('{"decision":"allow"}');
});
});
await new Promise<void>((r) => server.listen(0, r));
const port = (server.address() as { port: number }).port;
const url = `http://127.0.0.1:${port}/hook`;

const d = new HookDispatcher({
hooks: { PreToolUse: [{ hooks: [{ type: 'http', url }] }] },
});
const r = await d.dispatch({
event: 'PreToolUse',
cwd,
triggeredAt: 't',
payload: { tool: 'Bash' },
});
server.close();
expect(seen).toHaveLength(1);
expect(seen[0]?.method).toBe('POST');
expect(seen[0]?.body).toContain('PreToolUse');
expect(seen[0]?.ct).toBe('application/json');
expect(r.json?.decision).toBe('allow');
}, 5000);

it('http handler respects allowedHttpHookUrls whitelist', async () => {
const d = new HookDispatcher({
hooks: {
PreToolUse: [{ hooks: [{ type: 'http', url: 'https://evil.example.com/hook' }] }],
},
allowedHttpHookUrls: ['https://safe.example.com/'],
});
const r = await d.dispatch({
event: 'PreToolUse',
cwd,
triggeredAt: 't',
payload: { tool: 'Bash' },
});
expect(r.stderr).toMatch(/allowedHttpHookUrls/);
});

it('prompt handler produces additionalContext JSON output', async () => {
const d = new HookDispatcher({
hooks: {
UserPromptSubmit: [{ hooks: [{ type: 'prompt', prompt: 'Remember: always be polite.' }] }],
},
});
const r = await d.dispatch({
event: 'UserPromptSubmit',
cwd,
triggeredAt: 't',
payload: {},
});
expect(r.json?.additionalContext).toBe('Remember: always be polite.');
});

it('if-field filters command handlers via permission-rule syntax', async () => {
const d = new HookDispatcher({
hooks: {
PreToolUse: [
{
hooks: [
{
type: 'command',
command: 'echo SHOULD_NOT_RUN',
if: 'Bash(git push:*)',
},
{
type: 'command',
command: 'echo ran',
if: 'Bash(git diff:*)',
},
],
},
],
},
});
const r = await d.dispatch({
event: 'PreToolUse',
cwd,
triggeredAt: 't',
payload: { tool: 'Bash', input: { command: 'git diff --stat' } },
});
expect(r.stdout).not.toContain('SHOULD_NOT_RUN');
expect(r.stdout).toContain('ran');
});
});

describe('runCommand', () => {
Expand Down
Loading
Loading