369 lines
11 KiB
TypeScript
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;
|
|
}
|
|
},
|
|
}),
|
|
];
|
|
};
|