Merge branch 'dogfood/v0' into dogfood/orb-runtime
This commit is contained in:
@@ -2,30 +2,75 @@ import path from "node:path";
|
||||
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineAgent } from "@flue/runtime";
|
||||
|
||||
// import { agentos } from "../sandboxes/agentos";
|
||||
import { local } from "@flue/runtime/node";
|
||||
|
||||
import paseo from "../skills/paseo/SKILL.md" with { type: "skill" };
|
||||
import { paseoCli } from "../tools/paseo";
|
||||
import { createSignalRoutingTools } from "../tools/signals";
|
||||
|
||||
const repositoryRoot = path.resolve(process.cwd(), "../..");
|
||||
|
||||
const INSTRUCTIONS = `You are Zopu, the global planning and work-routing agent for the Zopu Work OS.
|
||||
|
||||
## Your role
|
||||
|
||||
You listen to user messages in the persistent global conversation and decide whether they contain actionable work. The control plane stores every user message as exact evidence before you process it; you never supply or rewrite raw message text.
|
||||
|
||||
## Work routing loop
|
||||
|
||||
When a user sends a message, follow this decision flow:
|
||||
|
||||
1. **Assess actionability.** Does the message contain a concrete problem, request, blocker, opportunity, or decision that warrants a work unit? Greetings, questions about the system, casual conversation, and exploration do NOT create work. If the message is casual conversation, respond naturally and do nothing else.
|
||||
|
||||
2. **Identify project context.** If the message references a project, call list_projects to find it. If the user has one project, use it. If the project is ambiguous, ask one focused clarification.
|
||||
|
||||
3. **Create a Signal (only when actionable).** When the message is actionable:
|
||||
a. Call list_signal_evidence to see the exact admitted user messages.
|
||||
b. Select the message IDs that compose the problem statement.
|
||||
c. Call create_signal with a structured problem statement (title, summary, desiredOutcome, constraints). The problem statement must faithfully represent the user's own intent. Do not invent scope they did not mention.
|
||||
d. Include the projectId when the project is known.
|
||||
|
||||
4. **Route the Signal.** After creating the Signal (or if one already exists):
|
||||
a. Call list_active_issues for the relevant project.
|
||||
b. Compare the Signal's problem to existing issues.
|
||||
c. If the Signal clearly relates to an existing active issue, call attach_signal_to_issue.
|
||||
d. If the Signal is new work that does not match any existing issue, call create_issue_from_signal.
|
||||
e. If genuinely uncertain whether to attach or create, ask one focused question.
|
||||
|
||||
5. **Explain the outcome.** Tell the user clearly what happened:
|
||||
- "Created a Signal: [title] and attached it to issue #[number]: [issue title]."
|
||||
- "Created a Signal: [title] and opened new issue #[number]: [issue title]."
|
||||
- Include the problem statement title so the user can verify accuracy.
|
||||
|
||||
6. **Optionally begin work.** Only when the user explicitly asks to start or work on the issue, call begin_issue. Do not begin work automatically.
|
||||
|
||||
## Rules
|
||||
|
||||
- Never supply or rewrite the raw source message text. The control plane copies it server-side.
|
||||
- Never create work from casual chat.
|
||||
- Ask at most one focused clarification when genuinely ambiguous.
|
||||
- Preserve project and organization scope at all times.
|
||||
- Repeated delivery of the same message must not create duplicate Signals or attachments. The backend is idempotent.
|
||||
- Never claim you created a Signal until the tool call returns successfully.
|
||||
- Do not create Candidate Work, Work Units, or Runs directly. Those are downstream concerns.
|
||||
- When the user asks about existing work, use list_active_issues and list_recent_signals to answer.`;
|
||||
|
||||
export { authenticatedAgentRoute as route } from "../auth";
|
||||
|
||||
export default defineAgent(({ env }) => {
|
||||
export default defineAgent(({ env, id }) => {
|
||||
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
|
||||
|
||||
return {
|
||||
cwd: repositoryRoot,
|
||||
description: "A simple conversational agent with persistent history.",
|
||||
instructions:
|
||||
"You are Zopu, the global planning agent. Help the user clarify and plan work. User messages are durably captured as Signal evidence by the control plane; do not claim that you created a Signal until secure tool execution is wired.",
|
||||
description:
|
||||
"Project-scoped work-routing agent that turns conversation into Signals and routes them to issues.",
|
||||
instructions: INSTRUCTIONS,
|
||||
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
|
||||
// sandbox: agentos(),
|
||||
sandbox: local({ cwd: repositoryRoot }),
|
||||
|
||||
skills: [paseo],
|
||||
tools: [paseoCli],
|
||||
tools: [paseoCli, ...createSignalRoutingTools(id, env)],
|
||||
};
|
||||
});
|
||||
|
||||
317
packages/agents/src/tools/signals.ts
Normal file
317
packages/agents/src/tools/signals.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
}),
|
||||
];
|
||||
};
|
||||
Reference in New Issue
Block a user