Files
zopu-code/packages/agents/src/tools/signals.ts
T

369 lines
11 KiB
TypeScript

import { parseAgentEnv } from "@code/env/agent";
import { ProjectIssueDispatchInput } from "@code/primitives/project-issue";
import { defineTool, dispatch } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from "convex/server";
import { Schema } from "effect";
import * as v from "valibot";
// ---------------------------------------------------------------------------
// Agent-gated Convex function references for the routing loop.
// ---------------------------------------------------------------------------
const listEvidenceRef = makeFunctionReference<
"query",
{ readonly organizationId: string; readonly token: string },
{
messageId: string;
rawText: string;
createdAt: number;
submissionId: string | null;
}[]
>("signalRouting:listEvidence");
const createSignalRef = makeFunctionReference<
"mutation",
{
readonly organizationId: string;
readonly projectId?: string;
messageIds: string[];
readonly problemStatement: {
readonly title: string;
readonly summary: string;
readonly desiredOutcome: string;
constraints: string[];
};
readonly processedByAgentInstanceId: string;
readonly token: string;
},
{ signalId: string }
>("signalRouting:createSignal");
const listSignalsRef = makeFunctionReference<
"query",
{
readonly organizationId: string;
readonly projectId?: string;
readonly token: string;
},
{
_id: string;
createdAt: number;
problemStatement: {
title: string;
summary: string;
desiredOutcome: string;
constraints: string[];
};
projectId: string | null;
}[]
>("signalRouting:listSignals");
const listActiveIssuesRef = makeFunctionReference<
"query",
{
readonly organizationId: string;
readonly projectId: string;
readonly token: string;
},
{
_id: string;
number: number;
title: string;
body: string;
status: string;
projectId: string;
updatedAt: number;
}[]
>("signalRouting:listActiveIssues");
const attachSignalToIssueRef = makeFunctionReference<
"mutation",
{
readonly organizationId: string;
readonly signalId: string;
readonly issueId: string;
readonly token: string;
},
{ attachmentId: string; alreadyAttached: boolean }
>("signalRouting:attachSignalToIssue");
const createIssueFromSignalRef = makeFunctionReference<
"mutation",
{
readonly organizationId: string;
readonly signalId: string;
readonly token: string;
},
{ issueId: string; projectId: string }
>("signalRouting:createIssueFromSignal");
const beginIssueRef = makeFunctionReference<
"mutation",
{
readonly organizationId: string;
readonly issueId: string;
readonly token: string;
},
{
readonly dispatchRequired: boolean;
readonly projectId: string;
readonly status: "queued" | "working";
}
>("signalRouting:beginIssue");
const markDispatchFailedRef = makeFunctionReference<
"mutation",
{
readonly error: string;
readonly issueId: string;
readonly organizationId: string;
readonly token: string;
},
null
>("signalRouting:markDispatchFailed");
const getProjectContextRef = makeFunctionReference<
"query",
{
readonly organizationId: string;
readonly projectId: string;
readonly token: string;
},
{
project: {
_id: string;
name: string;
description: string | null;
organizationId: string;
};
contextDocuments: {
kind: string;
path: string;
content: string;
revision: number;
}[];
}
>("signalRouting:getProjectContext");
const listProjectsRef = makeFunctionReference<
"query",
{
readonly organizationId: string;
readonly token: string;
},
{
_id: string;
name: string;
description: string | null;
organizationId: string;
}[]
>("signalRouting:listProjects");
// ---------------------------------------------------------------------------
// Tool factory: creates all routing tools bound to one agent instance.
//
// The `instanceId` is the organization ID (established by the authenticated
// route middleware). The tool never accepts organization ID as free input;
// it always comes from the bound instance. This is the hard tenancy boundary.
// ---------------------------------------------------------------------------
export const createSignalRoutingTools = (
instanceId: string,
runtimeEnv: Record<string, string | undefined>
) => {
const env = parseAgentEnv(runtimeEnv);
const client = new ConvexHttpClient(env.CONVEX_URL);
const token = env.FLUE_DB_TOKEN;
// The instance id is the organization id; tools are scoped to it.
const organizationId = instanceId;
return [
defineTool({
description:
"List projects in the current organization. Call this first to identify the project context the user is working in.",
name: "list_projects",
async run() {
return await client.query(listProjectsRef, {
organizationId,
token,
});
},
}),
defineTool({
description:
"Read the project name, description, and canonical context documents (README, product, design, tech, agents). Use this to understand the project before routing work.",
input: v.object({
projectId: v.string(),
}),
name: "get_project_context",
async run({ input }) {
return await client.query(getProjectContextRef, {
organizationId,
projectId: input.projectId,
token,
});
},
}),
defineTool({
description:
"List admitted user messages in the current conversation that have not yet been consumed by a Signal. These are the candidate evidence messages. Raw text is exact — never modify it.",
name: "list_signal_evidence",
async run() {
return await client.query(listEvidenceRef, {
organizationId,
token,
});
},
}),
defineTool({
description:
"Create a Signal from one or more admitted user messages plus a structured problem statement. Use ONLY when the conversation contains an actionable problem, request, blocker, or decision. Do NOT create Signals for casual chat, greetings, or exploration without a concrete problem. The problemStatement must faithfully represent the user's own words — do not invent or rewrite their intent.",
input: v.object({
messageIds: v.array(v.string()),
problemStatement: v.object({
constraints: v.array(v.string()),
desiredOutcome: v.string(),
summary: v.string(),
title: v.string(),
}),
projectId: v.optional(v.string()),
}),
name: "create_signal",
async run({ input }) {
return await client.mutation(createSignalRef, {
messageIds: input.messageIds,
organizationId,
problemStatement: input.problemStatement,
...(input.projectId === undefined
? {}
: { projectId: input.projectId }),
processedByAgentInstanceId: organizationId,
token,
});
},
}),
defineTool({
description:
"List recent Signals for a project (or organization-wide if no projectId). Use this to see what has already been captured.",
input: v.object({
projectId: v.optional(v.string()),
}),
name: "list_recent_signals",
async run({ input }) {
return await client.query(listSignalsRef, {
organizationId,
...(input.projectId === undefined
? {}
: { projectId: input.projectId }),
token,
});
},
}),
defineTool({
description:
"List active (open, queued, working, needs-input) ProjectIssues for a project. Use this to find existing issues that a new Signal might relate to before deciding whether to attach or create a new one.",
input: v.object({
projectId: v.string(),
}),
name: "list_active_issues",
async run({ input }) {
return await client.query(listActiveIssuesRef, {
organizationId,
projectId: input.projectId,
token,
});
},
}),
defineTool({
description:
"Attach a Signal to an existing ProjectIssue. This links the signal's evidence to an open issue. Idempotent: repeating the same attachment is safe and returns the existing relation. Use when the signal's problem clearly relates to an existing active issue.",
input: v.object({
issueId: v.string(),
signalId: v.string(),
}),
name: "attach_signal_to_issue",
async run({ input }) {
return await client.mutation(attachSignalToIssueRef, {
issueId: input.issueId,
organizationId,
signalId: input.signalId,
token,
});
},
}),
defineTool({
description:
"Create a new ProjectIssue from a Signal. The signal must be project-scoped. This also auto-attaches the signal to the new issue. Use when the signal's problem does not match any existing active issue.",
input: v.object({
signalId: v.string(),
}),
name: "create_issue_from_signal",
async run({ input }) {
return await client.mutation(createIssueFromSignalRef, {
organizationId,
signalId: input.signalId,
token,
});
},
}),
defineTool({
description:
"Begin working on a ProjectIssue by transitioning it to queued. Use only after the user explicitly confirms they want to start the issue. Returns the new status.",
input: v.object({
issueId: v.string(),
}),
name: "begin_issue",
async run({ input }) {
const outcome = await client.mutation(beginIssueRef, {
issueId: input.issueId,
organizationId,
token,
});
const dispatchInput = Schema.decodeUnknownSync(
ProjectIssueDispatchInput
)({
issueId: input.issueId,
kind: "project.issue.started",
projectId: outcome.projectId,
});
if (!outcome.dispatchRequired) {
return {
acceptedAt: "",
dispatchId: "",
status: outcome.status,
};
}
try {
const receipt = await dispatch({
agent: "project-manager",
id: input.issueId,
input: dispatchInput,
});
return {
acceptedAt: receipt.acceptedAt,
dispatchId: receipt.dispatchId,
status: outcome.status,
};
} catch (error) {
await client.mutation(markDispatchFailedRef, {
error: error instanceof Error ? error.message : String(error),
issueId: input.issueId,
organizationId,
token,
});
throw error;
}
},
}),
];
};