feat(agents): add signal tools to zopu agent

This commit is contained in:
-Puter
2026-07-23 17:16:59 +05:30
parent 6d86c8e538
commit d3381f978f
3 changed files with 244 additions and 2 deletions

View File

@@ -18,8 +18,16 @@ export default defineAgent(({ env }) => {
return {
cwd: repositoryRoot,
description: "A simple conversational agent with persistent history.",
instructions:
"You are Zopu, a concise and helpful assistant. Answer the user's request directly. Ask a clarifying question only when the request cannot be completed without one.",
instructions: `You are Zopu, a concise and helpful assistant. Answer the user's request directly. Ask a clarifying question only when the request cannot be completed without one.
## Signals
A Signal is your structured interpretation of the user's exact messages into a clear problem statement. Capture them precisely.
- Call list_signal_source_messages to see admitted user messages not yet used as evidence.
- Do NOT create a Signal for greetings, chitchat, or unclear exploration. Just respond.
- When the user's request is coherent and describes a real problem or goal, compose exactly one Signal: call create_signal once with the ordered messageIds and a structured problem statement {title, summary, desiredOutcome, constraints}.
- Never call create_signal more than once for the same request. It is idempotent on the ordered message selection, but you should still create at most one.
- After creating, report the structured problem statement back to the user (title, summary, desired outcome, constraints) so they can confirm or correct it.`,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
// sandbox: agentos(),
sandbox: local({ cwd: repositoryRoot }),

View File

@@ -0,0 +1,153 @@
/**
* Zopu Signal tools: candidate-evidence listing and structured Signal creation.
*
* SECURITY MODEL
* --------------
* Both tools call Convex functions (`signals:listAdmittedUnused`,
* `signals:createFromMessages`) that authorize via the authenticated caller
* identity (`requireOrganizationMember` → `ctx.auth.getUserIdentity()`). They
* therefore require a request-scoped {@link ConvexHttpClient} carrying the
* caller's Bearer JWT — exactly the client `authenticatedAgentRoute` builds
* per request. We never use a shared/global token or a service token here,
* because those carry no user identity and cannot satisfy membership checks.
*
* The factory closes over an authenticated client plus the resolved
* organization (agent instance) id. The agent instance id equals the
* organization id (the hard tenancy boundary enforced by the route), so
* `organizationId` and `conversationId` are never trusted from the agent.
*/
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { defineTool } from "@flue/runtime";
import type { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from "convex/server";
import * as v from "valibot";
// ---------------------------------------------------------------------------
// Typed function references (no generated backend bindings imported here, to
// keep the agents package decoupled from backend codegen at type-check time).
// ---------------------------------------------------------------------------
interface ConversationMessageSourceView {
readonly messageId: string;
readonly clientRequestId: string;
readonly rawText: string;
readonly submissionId: string | null;
readonly createdAt: number;
}
const listAdmittedUnused = makeFunctionReference<
"query",
{ readonly organizationId: Id<"organizations"> },
ConversationMessageSourceView[]
>("signals:listAdmittedUnused");
const createFromMessages = makeFunctionReference<
"mutation",
{
readonly organizationId: Id<"organizations">;
readonly projectId?: Id<"projects">;
readonly conversationId: string;
readonly messageIds: readonly string[];
readonly problemStatement: {
readonly title: string;
readonly summary: string;
readonly desiredOutcome: string;
readonly constraints: readonly string[];
};
readonly processedBy: {
readonly agentName: string;
readonly agentInstanceId: string;
};
},
{ readonly signalId: Id<"signals"> }
>("signals:createFromMessages");
/**
* Input/output schemas shared with the agent instructions.
*/
const problemStatementSchema = v.object({
title: v.pipe(v.string(), v.maxLength(200)),
summary: v.pipe(v.string(), v.maxLength(2000)),
desiredOutcome: v.pipe(v.string(), v.maxLength(2000)),
constraints: v.pipe(v.array(v.pipe(v.string(), v.maxLength(500))), v.maxLength(20)),
});
/**
* Build the Zopu Signal tools bound to one authenticated, organization-scoped
* agent instance.
*
* @param client A request-scoped authenticated Convex client carrying
* the caller's JWT. Built fresh per request; never shared.
* @param organizationId The caller's organization id. Equals the agent
* instance id and the global conversation id.
*/
export const createSignalTools = (
client: ConvexHttpClient,
organizationId: Id<"organizations">
) => {
// The conversation id is the organization id (global Zopu conversation).
const conversationId: string = organizationId;
const processedBy = {
agentInstanceId: organizationId,
agentName: "zopu",
};
return [
defineTool({
description:
"List the admitted user messages in this conversation that have not yet been used as Signal evidence, newest first. Use these messageIds as the ordered source selection when composing a Signal.",
name: "list_signal_source_messages",
output: v.object({
messages: v.array(
v.object({
messageId: v.string(),
rawText: v.string(),
createdAt: v.number(),
})
),
}),
async run() {
const messages = await client.query(listAdmittedUnused, {
organizationId,
});
return {
messages: messages.map((message) => ({
createdAt: message.createdAt,
messageId: message.messageId,
rawText: message.rawText,
})),
};
},
}),
defineTool({
description:
"Create exactly one Signal from an ordered selection of admitted user messages plus a structured problem statement. Idempotent: the same ordered message selection returns the existing Signal. Call this at most once per coherent request, never for greetings or unclear exploration.",
input: v.object({
messageIds: v.pipe(
v.array(v.string()),
v.minLength(1),
v.maxLength(50)
),
problemStatement: problemStatementSchema,
projectId: v.optional(v.pipe(v.string(), v.minLength(1))),
}),
name: "create_signal",
output: v.object({
signalId: v.string(),
}),
async run({ input }) {
const result = await client.mutation(createFromMessages, {
conversationId,
messageIds: [...input.messageIds],
organizationId,
problemStatement: input.problemStatement,
processedBy,
...(input.projectId === undefined
? {}
: { projectId: input.projectId as Id<"projects"> }),
});
return { signalId: result.signalId };
},
}),
];
};