feat(signals): project-scoped Zopu routing loop with secure tools
Add the complete project-scoped Zopu chat, Signal extraction, and work-routing loop: Backend (signalRouting.ts): - Agent-token-gated functions for the full routing cycle: list evidence, create signal, list signals, list active issues, attach signal to issue, create issue from signal, begin issue, get project context, list projects - attachSignalToIssue requires signal.projectId to exist and equal issue.projectId (org equality alone is insufficient) - createIssueFromSignal is idempotent: queries existing attachments before creating, returns created/reused flag - Duplicate attach retries return existing relation without emitting a duplicate event - All functions deny-by-default with agent token validation Schema: - Add signalIssueAttachments table with indexes by signal, issue, and the composite (signalId, issueId) for idempotent lookups Agent tools (tools/signals.ts): - Nine Flue tool definitions bound to the organization-scoped agent instance ID, calling the agent-gated Convex functions - Organization ID never accepted as free input; always from the bound instance Zopu agent (zopu.ts): - Comprehensive routing-loop instructions: assess actionability, identify project, create signal only when actionable, route via attach or create, explain outcome, optionally begin - Wiring of routing tools into the agent definition Tests (signalRouting.test.ts): - Authentication: invalid token rejected on every function - Project scoping: evidence, signals, issues scoped correctly - Idempotency: duplicate attach, duplicate create-from-signal, duplicate signal creation - Attach vs create: both paths verified - Cross-project rejection: signal and issue must be in same project - Org-scoped signal attach rejected (no projectId) - Provenance: exact raw text preserved through routing loop - Begin issue: transition and auth verification
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineTool } from "@flue/runtime";
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
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;
|
||||
},
|
||||
"queued" | "working"
|
||||
>("signalRouting:beginIssue");
|
||||
|
||||
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 }) {
|
||||
return await client.mutation(beginIssueRef, {
|
||||
issueId: input.issueId,
|
||||
organizationId,
|
||||
token,
|
||||
});
|
||||
},
|
||||
}),
|
||||
];
|
||||
};
|
||||
Reference in New Issue
Block a user