agenthub/src/cli/remoteClient.ts
chahinebrini f0c7be25f4 feat(agenthub): TSK-0118 — Ask primitive for autonomous decision-routing
- New Ask entity (ASK-####): schema + counter + paths(EntityType 'asks') +
  events(AgentHubEventType 'ask') + fsWatch(WATCHED + toEvent). Generic entities
  table, no migration.
- askService: createAsk routes to config.roles.architect.preferredAgent, NEVER
  the CEO (a to='ceo' is rerouted); answerAsk / escalateAsk (escalatedTo='ceo',
  single channel) / getAsk / listAsks. Authority-policy JSDoc.
- Asks kept OUT of FTS5: Index.upsert gains a { fts?: boolean } option; askService
  upserts with fts:false, so 'memory search' never returns asks.
- routes: POST/GET /asks, GET /asks/:id, POST /asks/:id/{answer,escalate}, each
  emitChange type:'ask'.
- CLI 'ask <q> --from [--task][--wait][--timeout]' (SSE reconnect wait until
  status!=pending) + ask list/answer/escalate; remoteClient ask methods.
- MCP agenthub_ask (wait via waitForTask, now woken by 'ask' events) +
  agenthub_ask_list/answer/escalate; agenthub_work architect branch surfaces
  pending asks ({reviews,asks,messages}).
- Unattended mode (invocation flag): work.ts ctx + CLI 'work --unattended' +
  agenthub_work schema + LOOP reminder ('call agenthub_ask instead of pausing').
- tests: +askService.test.ts (routing/answer/escalate/list/FTS-exclusion),
  +ask-wait.test.ts (routes roundtrip + SSE wait: answer resolves, no-answer
  times out cleanly)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 01:19:42 +02:00

183 lines
7.1 KiB
TypeScript

import type { Task, Handoff, Decision, Memory, Message, Ask, ActivityItem } from '../core/schema.js';
import type { IndexEntry } from '../core/index.js';
import type { InboxMessage } from '../core/services/messageService.js';
import type { TaskLogEntry } from '../core/services/taskLogService.js';
export class RemoteError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function request<T>(baseUrl: string, method: string, path: string, body?: unknown): Promise<T> {
const url = `${baseUrl}${path}`;
let res: Response;
try {
res = await fetch(url, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new RemoteError(0, `Cannot reach AgentHub server at ${baseUrl}: ${message}`);
}
const text = await res.text();
if (!res.ok) {
throw new RemoteError(res.status, text || `HTTP ${res.status}`);
}
if (!text) {
throw new RemoteError(res.status, 'Empty response from server');
}
return JSON.parse(text) as T;
}
export const remoteClient = {
async getStatus(baseUrl: string): Promise<string> {
return request<{ body: string }>(baseUrl, 'GET', '/status').then((r) => r.body);
},
async announce(baseUrl: string, agent: string, role?: string, action: 'joined' | 'left' = 'joined'): Promise<void> {
await request<{ ok: boolean }>(baseUrl, 'POST', '/announce', { agent, role, action });
},
async updateStatus(baseUrl: string): Promise<string> {
return request<{ body: string }>(baseUrl, 'POST', '/status/update').then((r) => r.body);
},
async createTask(baseUrl: string, options: Partial<Task>): Promise<Task> {
return request<Task>(baseUrl, 'POST', '/tasks', options);
},
async listTasks(baseUrl: string, filters?: { status?: string; role?: string }): Promise<IndexEntry[]> {
const params = new URLSearchParams((filters ?? {}) as Record<string, string>);
const qs = params.toString();
return request<IndexEntry[]>(baseUrl, 'GET', `/tasks${qs ? '?' + qs : ''}`);
},
async getTask(baseUrl: string, id: string): Promise<{ task: Task; body: string }> {
return request<{ task: Task; body: string }>(baseUrl, 'GET', `/tasks/${id}`);
},
async claimTask(baseUrl: string, id: string, agentName: string): Promise<Task> {
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'in_progress', assignedTo: agentName });
},
async doneTask(
baseUrl: string,
id: string,
meta?: { tokens?: number; duration?: number; by?: string },
): Promise<Task> {
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, {
status: 'done',
doneTokens: meta?.tokens,
doneDuration: meta?.duration,
doneBy: meta?.by,
});
},
async assignTask(baseUrl: string, id: string, agentName: string): Promise<Task> {
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { assignedTo: agentName });
},
async reviewTask(baseUrl: string, id: string): Promise<Task> {
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'review' });
},
async reopenTask(baseUrl: string, id: string): Promise<Task> {
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'open' });
},
async getTaskActivity(baseUrl: string, id: string): Promise<ActivityItem[]> {
return request<ActivityItem[]>(baseUrl, 'GET', `/tasks/${id}/activity`);
},
async appendTaskLog(
baseUrl: string,
id: string,
entry: { text: string; agent?: string; level?: string },
): Promise<TaskLogEntry> {
return request<TaskLogEntry>(baseUrl, 'POST', `/tasks/${id}/log`, entry);
},
async createHandoff(baseUrl: string, options: Partial<Handoff>): Promise<Handoff> {
return request<Handoff>(baseUrl, 'POST', '/handoffs', options);
},
async listHandoffs(baseUrl: string): Promise<IndexEntry[]> {
return request<IndexEntry[]>(baseUrl, 'GET', '/handoffs');
},
async getHandoff(baseUrl: string, id: string): Promise<{ handoff: Handoff; body: string }> {
return request<{ handoff: Handoff; body: string }>(baseUrl, 'GET', `/handoffs/${id}`);
},
async createDecision(baseUrl: string, options: Partial<Decision>): Promise<Decision> {
return request<Decision>(baseUrl, 'POST', '/decisions', options);
},
async listDecisions(baseUrl: string): Promise<IndexEntry[]> {
return request<IndexEntry[]>(baseUrl, 'GET', '/decisions');
},
async addMemory(baseUrl: string, options: Partial<Memory>): Promise<Memory> {
return request<Memory>(baseUrl, 'POST', '/memory', options);
},
async searchMemory(baseUrl: string, query: string): Promise<IndexEntry[]> {
return request<IndexEntry[]>(baseUrl, 'GET', `/memory/search?q=${encodeURIComponent(query)}`);
},
async listMemory(baseUrl: string): Promise<IndexEntry[]> {
return request<IndexEntry[]>(baseUrl, 'GET', '/memory');
},
async sendMessage(baseUrl: string, options: Partial<Message>): Promise<Message> {
return request<Message>(baseUrl, 'POST', '/messages', options);
},
async getInbox(baseUrl: string, agent: string, unreadOnly = false): Promise<InboxMessage[]> {
const q = `?agent=${encodeURIComponent(agent)}${unreadOnly ? '&unread=1' : ''}`;
return request<InboxMessage[]>(baseUrl, 'GET', `/messages${q}`);
},
async markMessageRead(baseUrl: string, id: string): Promise<Message> {
return request<Message>(baseUrl, 'POST', `/messages/${id}/read`);
},
async ackMessage(baseUrl: string, id: string, by?: string): Promise<Message> {
return request<Message>(baseUrl, 'POST', `/messages/${id}/ack`, by ? { by } : undefined);
},
async getMessage(baseUrl: string, id: string): Promise<{ message: Message; body: string }> {
return request<{ message: Message; body: string }>(baseUrl, 'GET', `/messages/${id}`);
},
async createAsk(baseUrl: string, options: Partial<Ask>): Promise<Ask> {
return request<Ask>(baseUrl, 'POST', '/asks', options);
},
async listAsks(baseUrl: string, filters?: { to?: string; status?: string }): Promise<Ask[]> {
const params = new URLSearchParams((filters ?? {}) as Record<string, string>);
const qs = params.toString();
return request<Ask[]>(baseUrl, 'GET', `/asks${qs ? '?' + qs : ''}`);
},
async getAsk(baseUrl: string, id: string): Promise<{ ask: Ask; body: string }> {
return request<{ ask: Ask; body: string }>(baseUrl, 'GET', `/asks/${id}`);
},
async answerAsk(baseUrl: string, id: string, text: string, by?: string): Promise<Ask> {
return request<Ask>(baseUrl, 'POST', `/asks/${id}/answer`, { text, by });
},
async escalateAsk(baseUrl: string, id: string, note?: string): Promise<Ask> {
return request<Ask>(baseUrl, 'POST', `/asks/${id}/escalate`, note ? { note } : undefined);
},
async delegate(baseUrl: string, auto: boolean): Promise<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }> {
return request<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }>(baseUrl, 'POST', `/delegate?auto=${auto}`);
},
};