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:
-Puter
2026-07-24 20:48:18 +05:30
parent 1324316fee
commit 03fc32bd6d
5 changed files with 1658 additions and 6 deletions
+317
View File
@@ -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,
});
},
}),
];
};