feat(agents): add signal tools to zopu agent
This commit is contained in:
@@ -18,8 +18,16 @@ export default defineAgent(({ env }) => {
|
|||||||
return {
|
return {
|
||||||
cwd: repositoryRoot,
|
cwd: repositoryRoot,
|
||||||
description: "A simple conversational agent with persistent history.",
|
description: "A simple conversational agent with persistent history.",
|
||||||
instructions:
|
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.
|
||||||
"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}`,
|
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
|
||||||
// sandbox: agentos(),
|
// sandbox: agentos(),
|
||||||
sandbox: local({ cwd: repositoryRoot }),
|
sandbox: local({ cwd: repositoryRoot }),
|
||||||
|
|||||||
153
packages/agents/src/tools/signals.ts
Normal file
153
packages/agents/src/tools/signals.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -53,6 +53,19 @@ interface ComposedSignal {
|
|||||||
readonly sources: readonly SignalSourceView[];
|
readonly sources: readonly SignalSourceView[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An admitted user conversation message presented to the agent as candidate
|
||||||
|
* Signal evidence. The agent selects from these by `messageId`; raw text and
|
||||||
|
* timestamps are copied server-side and never accepted from the agent.
|
||||||
|
*/
|
||||||
|
interface ConversationMessageSourceView {
|
||||||
|
readonly messageId: string;
|
||||||
|
readonly clientRequestId: string;
|
||||||
|
readonly rawText: string;
|
||||||
|
readonly submissionId: string | null;
|
||||||
|
readonly createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
const toSignalView = (doc: Doc<"signals">): SignalView => ({
|
const toSignalView = (doc: Doc<"signals">): SignalView => ({
|
||||||
_creationTime: doc._creationTime,
|
_creationTime: doc._creationTime,
|
||||||
_id: doc._id,
|
_id: doc._id,
|
||||||
@@ -78,6 +91,16 @@ const toSourceView = (doc: Doc<"signalSources">): SignalSourceView => ({
|
|||||||
submissionId: doc.submissionId ?? null,
|
submissionId: doc.submissionId ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const toConversationMessageSourceView = (
|
||||||
|
doc: Doc<"conversationMessages">
|
||||||
|
): ConversationMessageSourceView => ({
|
||||||
|
clientRequestId: doc.clientRequestId,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
messageId: doc.messageId,
|
||||||
|
rawText: doc.rawText,
|
||||||
|
submissionId: doc.submissionId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a deterministic, collision-safe key from the organization, the
|
* Build a deterministic, collision-safe key from the organization, the
|
||||||
* conversation, and the ordered message selection. The key is the JSON
|
* conversation, and the ordered message selection. The key is the JSON
|
||||||
@@ -348,6 +371,64 @@ export const list = query({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List the bounded set of admitted user messages in the organization's
|
||||||
|
* conversation that have not yet been consumed as a Signal source, newest
|
||||||
|
* first. These are the candidate messages an agent should select from when
|
||||||
|
* composing a new Signal.
|
||||||
|
*
|
||||||
|
* Authorization: the authenticated identity must be a member of the target
|
||||||
|
* organization. The conversation id equals the organization id; the caller
|
||||||
|
* never supplies tenancy. Cross-organization access is denied.
|
||||||
|
*
|
||||||
|
* Bounded: capped at 100 rows to keep agent evidence selection tractable.
|
||||||
|
*/
|
||||||
|
export const listAdmittedUnused = query({
|
||||||
|
args: {
|
||||||
|
organizationId: v.id("organizations"),
|
||||||
|
},
|
||||||
|
handler: async (
|
||||||
|
ctx,
|
||||||
|
args
|
||||||
|
): Promise<ConversationMessageSourceView[]> => {
|
||||||
|
await requireOrganizationMember(ctx, args.organizationId);
|
||||||
|
const conversationId = args.organizationId;
|
||||||
|
|
||||||
|
const admitted = await ctx.db
|
||||||
|
.query("conversationMessages")
|
||||||
|
.withIndex("by_organization_and_message", (q) =>
|
||||||
|
q
|
||||||
|
.eq("organizationId", args.organizationId)
|
||||||
|
.eq("conversationId", conversationId)
|
||||||
|
)
|
||||||
|
.order("desc")
|
||||||
|
.take(100);
|
||||||
|
|
||||||
|
const candidates = admitted.filter(
|
||||||
|
(message) =>
|
||||||
|
message.role === "user" && message.status === "admitted"
|
||||||
|
);
|
||||||
|
|
||||||
|
const unused: ConversationMessageSourceView[] = [];
|
||||||
|
for (const message of candidates) {
|
||||||
|
const consumed = await ctx.db
|
||||||
|
.query("signalSources")
|
||||||
|
.withIndex("by_organization_and_message", (q) =>
|
||||||
|
q
|
||||||
|
.eq("organizationId", args.organizationId)
|
||||||
|
.eq("conversationId", conversationId)
|
||||||
|
.eq("messageId", message.messageId)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
if (!consumed) {
|
||||||
|
unused.push(toConversationMessageSourceView(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unused;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get one Signal by id with its composed sources. Membership in the signal's
|
* Get one Signal by id with its composed sources. Membership in the signal's
|
||||||
* organization is required; access to another organization's signal is denied.
|
* organization is required; access to another organization's signal is denied.
|
||||||
|
|||||||
Reference in New Issue
Block a user