Skip to content

Commit a9c778d

Browse files
committed
✨ feat(claw): add context tools, deny escalation, batch audit, hot-reload config
- claw_set_context/claw_get_context tools for persistent working memory (180 total tools) - denyEscalation API endpoint (POST /claws/:id/deny-escalation) with reason param - Batch audit log inserts (single query instead of N serial writes) - Hot-reload config on PUT /claws/:id (updateClawConfig in manager) - Auto-fail state after 5 consecutive errors (vs auto-pause) - Idle cycle tracking and cleanup (90d history,
1 parent 4186fb5 commit a9c778d

23 files changed

Lines changed: 688 additions & 131 deletions

packages/core/src/agent/tools/tool-tags.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,12 +1072,13 @@ describe('TOOL_SEARCH_TAGS — aggregate statistics', () => {
10721072
}
10731073
});
10741074

1075-
it('the exact tool count matches the source (159 tools)', () => {
1075+
it('the exact tool count matches the source (180 tools)', () => {
10761076
// Count from source: email(6) + git(7) + memory(7) + tasks(6) + notes(5) +
10771077
// calendar(5) + contacts(5) + bookmarks(5) + expenses(8) + habits(8) +
10781078
// files(8) + web(4) + code(5) + image(3) + audio(5) + pdf(3) + goals(8) +
10791079
// extraction(2) + custom_data(11) + weather(2) + utility(22) +
1080-
// dynamic(8) + config(3) + triggers(6) + plans(7) = 159
1081-
expect(Object.keys(TOOL_SEARCH_TAGS).length).toBe(159);
1080+
// dynamic(8) + config(3) + triggers(6) + plans(7) + claw(16) + subagent(6) + crew(3) +
1081+
// fleet(7) + mcp(7) + souls(3) = 180
1082+
expect(Object.keys(TOOL_SEARCH_TAGS).length).toBe(180);
10821083
});
10831084
});

packages/core/src/agent/tools/tool-tags.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,8 @@ export const TOOL_SEARCH_TAGS: Record<string, readonly string[]> = {
351351
claw_update_config: ['claw', 'config', 'update', 'self', 'adapt', 'modify', 'settings'],
352352
claw_send_agent_message: ['claw', 'message', 'send', 'agent', 'communicate', 'inbox'],
353353
claw_reflect: ['claw', 'reflect', 'evaluate', 'introspect', 'performance', 'progress'],
354+
claw_set_context: ['claw', 'context', 'set', 'memory', 'persistent', 'working memory', 'state'],
355+
claw_get_context: ['claw', 'context', 'get', 'memory', 'retrieve', 'working memory', 'state'],
354356
create_claw: ['claw', 'create', 'agent', 'autonomous', 'spawn', 'new'],
355357
list_claws: ['claw', 'list', 'status', 'agents', 'running'],
356358
start_claw: ['claw', 'start', 'run', 'begin', 'launch'],

packages/core/src/services/claw-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ export interface IClawService {
255255

256256
// ---- Escalation ----
257257
approveEscalation(clawId: string, userId: string): Promise<boolean>;
258+
denyEscalation(clawId: string, userId: string, reason?: string): Promise<boolean>;
258259

259260
// ---- Service Lifecycle ----
260261
start(): Promise<void>;

packages/gateway/src/db/migrations/postgres/001_initial_schema.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1131,7 +1131,7 @@ CREATE TABLE IF NOT EXISTS claws (
11311131
user_id TEXT NOT NULL DEFAULT 'default',
11321132
name TEXT NOT NULL,
11331133
mission TEXT NOT NULL,
1134-
mode TEXT NOT NULL DEFAULT 'cyclic',
1134+
mode TEXT NOT NULL DEFAULT 'continuous',
11351135
allowed_tools JSONB DEFAULT '[]',
11361136
limits JSONB NOT NULL DEFAULT '{}',
11371137
interval_ms INTEGER,

packages/gateway/src/db/migrations/postgres/022_claws.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ CREATE TABLE IF NOT EXISTS claws (
77
user_id TEXT NOT NULL DEFAULT 'default',
88
name TEXT NOT NULL,
99
mission TEXT NOT NULL,
10-
mode TEXT NOT NULL DEFAULT 'cyclic',
10+
mode TEXT NOT NULL DEFAULT 'continuous',
1111
allowed_tools JSONB DEFAULT '[]',
1212
limits JSONB NOT NULL DEFAULT '{}',
1313
interval_ms INTEGER,
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- Claw fixes migration
2+
-- 1. Fix mode column default: 'cyclic' is not a valid ClawMode, correct to 'continuous'
3+
-- 2. Fix any existing rows stuck with mode='cyclic'
4+
5+
ALTER TABLE claws ALTER COLUMN mode SET DEFAULT 'continuous';
6+
7+
UPDATE claws SET mode = 'continuous' WHERE mode = 'cyclic';

packages/gateway/src/db/repositories/claws.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -592,9 +592,33 @@ export class ClawsRepository extends BaseRepository {
592592
durationMs: number;
593593
category?: string;
594594
}>): Promise<void> {
595+
if (entries.length === 0) return;
596+
597+
// Single batch INSERT instead of N serial queries
598+
const valuePlaceholders: string[] = [];
599+
const params: unknown[] = [];
600+
let idx = 1;
601+
595602
for (const entry of entries) {
596-
await this.saveAuditEntry(entry);
603+
valuePlaceholders.push(`($${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++})`);
604+
params.push(
605+
generateId('aud'),
606+
entry.clawId,
607+
entry.cycleNumber,
608+
entry.toolName,
609+
JSON.stringify(entry.toolArgs),
610+
(entry.toolResult ?? '').slice(0, 10_000),
611+
entry.success,
612+
entry.durationMs,
613+
entry.category ?? this.categorizeToolCall(entry.toolName)
614+
);
597615
}
616+
617+
await this.execute(
618+
`INSERT INTO claw_audit_log (id, claw_id, cycle_number, tool_name, tool_args, tool_result, success, duration_ms, category)
619+
VALUES ${valuePlaceholders.join(', ')}`,
620+
params
621+
);
598622
}
599623

600624
async getAuditLog(

packages/gateway/src/routes/channels-groups.test.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@ import { requestId } from '../middleware/request-id.js';
1414
import { errorHandler } from '../middleware/error-handler.js';
1515

1616
// Mock Channel Service
17-
const mockGetChannel = vi.fn();
18-
const mockGetStatus = vi.fn();
19-
const mockListGroups = vi.fn();
20-
const mockGetGroup = vi.fn();
21-
const mockFetchGroupHistory = vi.fn();
17+
const { mockGetChannel, mockGetStatus, mockListGroups, mockGetGroup, mockFetchGroupHistory } = vi.hoisted(() => ({
18+
mockGetChannel: vi.fn(),
19+
mockGetStatus: vi.fn(),
20+
mockListGroups: vi.fn(),
21+
mockGetGroup: vi.fn(),
22+
mockFetchGroupHistory: vi.fn(),
23+
}));
2224

23-
vi.mock('@ownpilot/core', () => ({
25+
vi.mock('@ownpilot/core', async (importOriginal) => ({
26+
...await importOriginal<Record<string, unknown>>(),
2427
getChannelService: vi.fn().mockReturnValue({
2528
getChannel: mockGetChannel,
2629
}),

packages/gateway/src/routes/claws.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ vi.mock('../services/claw-service.js', () => ({
1717
getClawService: mockGetClawService,
1818
}));
1919

20+
const mockGetClawManager = vi.fn().mockReturnValue({
21+
updateClawConfig: vi.fn(),
22+
});
23+
vi.mock('../services/claw-manager.js', () => ({
24+
getClawManager: mockGetClawManager,
25+
}));
26+
2027
vi.mock('./helpers.js', async (importOriginal) => {
2128
const actual = await importOriginal<Record<string, unknown>>();
2229
return {
@@ -58,6 +65,7 @@ function createMockService() {
5865
getHistory: vi.fn(),
5966
sendMessage: vi.fn(),
6067
approveEscalation: vi.fn(),
68+
denyEscalation: vi.fn(),
6169
};
6270
}
6371

@@ -351,4 +359,35 @@ describe('Claws Routes', () => {
351359
expect(res.status).toBe(404);
352360
});
353361
});
362+
363+
describe('POST /claws/:id/deny-escalation', () => {
364+
it('should deny escalation', async () => {
365+
service.denyEscalation.mockResolvedValue(true);
366+
367+
const res = await app.request('/claws/claw-1/deny-escalation', { method: 'POST' });
368+
expect(res.status).toBe(200);
369+
370+
const body = await res.json();
371+
expect(body.data.denied).toBe(true);
372+
});
373+
374+
it('should pass reason to service', async () => {
375+
service.denyEscalation.mockResolvedValue(true);
376+
377+
const res = await app.request('/claws/claw-1/deny-escalation', {
378+
method: 'POST',
379+
headers: { 'Content-Type': 'application/json' },
380+
body: JSON.stringify({ reason: 'Not needed' }),
381+
});
382+
expect(res.status).toBe(200);
383+
expect(service.denyEscalation).toHaveBeenCalledWith('claw-1', 'user-1', 'Not needed');
384+
});
385+
386+
it('should return 404 if no pending escalation', async () => {
387+
service.denyEscalation.mockResolvedValue(false);
388+
389+
const res = await app.request('/claws/claw-1/deny-escalation', { method: 'POST' });
390+
expect(res.status).toBe(404);
391+
});
392+
});
354393
});

packages/gateway/src/routes/claws.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,28 @@ clawRoutes.post('/:id/approve-escalation', async (c) => {
304304
}
305305
});
306306

307+
// POST /:id/deny-escalation
308+
clawRoutes.post('/:id/deny-escalation', async (c) => {
309+
try {
310+
const userId = getUserId(c);
311+
const { id } = c.req.param();
312+
const body = await c.req.json().catch(() => ({}));
313+
const reason = typeof body.reason === 'string' ? body.reason : undefined;
314+
const service = getClawService();
315+
316+
const denied = await service.denyEscalation(id, userId, reason);
317+
if (!denied) {
318+
return apiError(c, {
319+
code: ERROR_CODES.NOT_FOUND,
320+
message: 'No pending escalation or claw not found',
321+
}, 404);
322+
}
323+
return apiResponse(c, { denied: true });
324+
} catch (err) {
325+
return apiError(c, { code: ERROR_CODES.INTERNAL_ERROR, message: getErrorMessage(err) }, 500);
326+
}
327+
});
328+
307329
// =============================================================================
308330
// 3. GENERIC DYNAMIC ROUTE (must be last)
309331
// =============================================================================
@@ -357,6 +379,11 @@ clawRoutes.put('/:id', async (c) => {
357379
if (!updated) {
358380
return apiError(c, { code: ERROR_CODES.NOT_FOUND, message: 'Claw not found' }, 404);
359381
}
382+
383+
// Hot-reload in-memory config so changes take effect without restart
384+
const { getClawManager } = await import('../services/claw-manager.js');
385+
getClawManager().updateClawConfig(id, updated);
386+
360387
return apiResponse(c, updated);
361388
} catch (err) {
362389
return apiError(c, { code: ERROR_CODES.INTERNAL_ERROR, message: getErrorMessage(err) }, 500);

0 commit comments

Comments
 (0)