repositories / will
will
owned by admin
src/agent/tools.ts
Rawimport { randomUUID } from 'node:crypto'
import { defineTool } from '@earendil-works/pi-coding-agent'
import { Type } from 'typebox'
import type { RecallQuery } from '../shared/api.ts'
import { MAINTENANCE_OPS } from '../shared/api.ts'
import type { DeployPlatformClient } from './deploy-client.ts'
import type { Outbox } from './outbox.ts'
import type { ServiceClient } from './service-client.ts'
/**
* will-owned custom tools (spec AC11..AC20): chat_send, recall, maintenance,
* and the scoped deployment_rollback. ZAI tools are defined in zai/tools.ts.
*/
export interface ToolDeps {
outbox: Outbox
service: ServiceClient
deploy?: DeployPlatformClient | undefined
chatId?: number | undefined
/** Kick outbound delivery soon (spool drain + outbox flush), debounced by main. */
onChatQueued?: (() => void) | undefined
}
function ok(text: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify(text) }], details: {} }
}
function fail(message: string) {
return {
content: [{ type: 'text' as const, text: JSON.stringify({ ok: false, error: message }) }],
isError: true,
details: {},
}
}
export function createWillTools(deps: ToolDeps) {
const chatSend = defineTool({
name: 'chat_send',
label: 'Send chat message',
description:
'Send a text message (and optionally a stored media digest) to your Telegram group. ' +
'This is your voice in the friendship channel; write freely, no report format.',
parameters: Type.Object({
text: Type.Optional(Type.String({ description: 'message text' })),
mediaDigest: Type.Optional(Type.String({ description: 'media digest to attach' })),
mediaType: Type.Optional(Type.String({ description: 'mime type of the media' })),
replyToMessageId: Type.Optional(
Type.Number({ description: 'telegram message id to reply to' }),
),
}),
async execute(_id, params) {
if (deps.chatId === undefined) return fail('telegram chat not configured')
if (params.text === undefined && params.mediaDigest === undefined) {
return fail('nothing to send: text or mediaDigest required')
}
await deps.outbox.enqueue({
id: `out-${randomUUID()}`,
chatId: deps.chatId,
...(params.text !== undefined ? { text: params.text } : {}),
...(params.mediaDigest !== undefined ? { mediaDigest: params.mediaDigest } : {}),
...(params.mediaType !== undefined ? { mediaType: params.mediaType } : {}),
...(params.replyToMessageId !== undefined
? { replyToMessageId: params.replyToMessageId }
: {}),
})
return ok({ queued: true })
},
})
const recall = defineTool({
name: 'recall',
label: 'Recall history',
description:
'Query your own memory: chat, audit, deployment, or media-analysis rows, ' +
'hot and archived alike. Read-only; use it before assuming anything about the past.',
parameters: Type.Object({
kind: Type.Union([
Type.Literal('chat'),
Type.Literal('audit'),
Type.Literal('deploy'),
Type.Literal('analysis'),
]),
since: Type.Optional(Type.String({ description: 'iso timestamp lower bound' })),
until: Type.Optional(Type.String({ description: 'iso timestamp upper bound' })),
q: Type.Optional(Type.String({ description: 'substring filter (text or payload)' })),
digest: Type.Optional(Type.String({ description: 'media digest for kind=analysis' })),
limit: Type.Optional(Type.Number({ description: 'max rows, default 50' })),
}),
async execute(_id, params) {
const query: RecallQuery = {
kind: params.kind,
...(params.since !== undefined ? { since: params.since } : {}),
...(params.until !== undefined ? { until: params.until } : {}),
...(params.q !== undefined ? { q: params.q } : {}),
...(params.digest !== undefined ? { digest: params.digest } : {}),
...(params.limit !== undefined ? { limit: params.limit } : {}),
}
try {
const res = await deps.service.recall(query)
return ok({ ok: true, cold: res.cold, rows: res.rows })
} catch (err) {
return fail(err instanceof Error ? err.message : String(err))
}
},
})
const maintenanceStatus = defineTool({
name: 'maintenance_status',
label: 'Maintenance status',
description:
'Read service health: storage state and free space, table counts, db size, ' +
'schema version, and the last sqlite quick_check.',
parameters: Type.Object({}),
async execute() {
try {
const status = await deps.service.status()
return ok({ ok: true, status })
} catch (err) {
return fail(err instanceof Error ? err.message : String(err))
}
},
})
const maintenanceRun = defineTool({
name: 'maintenance_run',
label: 'Run maintenance',
description:
'Run one explicit maintenance operation on the service: quick_check, ' +
'archive_cycle, vacuum, or reconcile. Structural repairs are not a tool; ' +
'they ship as tested migrations.',
parameters: Type.Object({
op: Type.Union([
Type.Literal('quick_check'),
Type.Literal('archive_cycle'),
Type.Literal('vacuum'),
Type.Literal('reconcile'),
]),
}),
async execute(_id, params) {
if (!(MAINTENANCE_OPS as readonly string[]).includes(params.op)) {
return fail(`unknown op ${params.op}`)
}
try {
const result = await deps.service.maintenance(params.op)
return ok({ ok: true, result })
} catch (err) {
return fail(err instanceof Error ? err.message : String(err))
}
},
})
const deploymentRollback = defineTool({
name: 'deployment_rollback',
label: 'Roll back deployment',
description:
'Restore the immediately previous promoted deployment revision after a semantic ' +
'failure you diagnosed. One shot; a reconciliation barrier then requires your ' +
'revert commit naming the failed revision.',
parameters: Type.Object({ reason: Type.String({ description: 'why this revision is bad' }) }),
async execute(_id, _params) {
if (!deps.deploy) {
return fail('deployment platform not configured (WILL_DEPLOY_API_URL); cannot roll back')
}
const res = await deps.deploy.rollbackPrevious()
if (!res.ok) return fail(res.error ?? 'rollback failed')
return ok({ ok: true, ...(res.revision !== undefined ? { restored: res.revision } : {}) })
},
})
return [chatSend, recall, maintenanceStatus, maintenanceRun, deploymentRollback]
}