Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/agent/agent/hooks/builder-delegation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { defineHook } from "eve/hooks";
import {
builderDelegationState,
recordBuilderDelegation,
} from "../lib/builder-delegation";
import { attribute, purposeOf } from "../lib/session-purpose";

export default defineHook({
events: {
"actions.requested"(event, ctx) {
if (
purposeOf(ctx) !== "builder" ||
attribute(ctx, "commandType") !== "CREATE_AGENT"
) {
return;
}

const next = recordBuilderDelegation(
builderDelegationState.get(),
event.data.turnId,
event.data.actions,
);
builderDelegationState.update(() => next);
},
},
});
2 changes: 1 addition & 1 deletion apps/agent/agent/instructions/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function builderTaskMarkdown(
): string {
const task =
commandType === "CREATE_AGENT"
? `This private CRM chat turn is authorized to create or revise an agent. Call agent_builder exactly once. Pass the complete request, the conversation's relevant decisions, every tagged resource, and your understanding of any attachment. Do not call research tools or mutate CRM records yourself. If the specialist returns needs_input, call ask_question with exactly its question, options, and freeform policy instead of replying with a plain-text question. Ask exactly one decision at a time and never bundle several missing details into one prompt. After the answer, ask another question only if the build remains materially blocked. Ask only when the answer materially changes the trigger, records, integrations, schedule, outcome, or side effect. Do not interrupt a sufficiently specific request or ask about optional polish. If the specialist returns draft_ready, relay its concise summary and explain that the draft is ready for human review and is not deployed yet.`
? `This private CRM chat turn is authorized to create or revise an agent. Call agent_builder exactly once. Pass the complete request, the conversation's relevant decisions, every tagged resource, and your understanding of any attachment. Do not call research tools or mutate CRM records yourself. The specialist asks any essential clarification directly through ask_question and returns only when the draft is ready. Never retry agent_builder in the same turn. If the specialist fails, explain that the build could not finish and ask the user to try again instead of delegating again. If the specialist returns draft_ready, relay its concise summary and explain that the draft is ready for human review and is not deployed yet.`
: `This is a private CRM assistant chat. Answer the user's question directly. Use tagged records as scope and use available read-only CRM and research tools when evidence is needed. Use list_deals for pipeline-wide, open-deal, or inactivity questions and follow its pagination until the requested scope is complete. The chat renders list_deals output as a structured deal list. Do not restate or enumerate individual deal rows in prose, bullets, or tables; the structured list is the sole row-level presentation. Give only a concise synthesis, caveats, and useful next actions after the tool results. If one materially necessary decision is missing, call ask_question with one focused follow-up instead of guessing; do not interrupt for optional detail. Do not call agent_builder, create an agent draft, or mutate CRM records on this turn. Agent creation begins only from an explicit request to create or build one. Be concise, distinguish CRM evidence from inference, and say when the CRM does not contain the answer.`;

return needsTitle
Expand Down
45 changes: 45 additions & 0 deletions apps/agent/agent/lib/builder-delegation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { defineState } from "eve/context";

type BuilderDelegationAction = {
callId: string;
kind: string;
subagentName?: string;
};

type BuilderDelegationState = {
turnId: string | null;
callIds: string[];
};

export const builderDelegationState = defineState<BuilderDelegationState>(
"crm.builder-delegation",
() => ({ turnId: null, callIds: [] }),
);

export function recordBuilderDelegation(
state: BuilderDelegationState,
turnId: string,
actions: readonly BuilderDelegationAction[],
): BuilderDelegationState {
const current =
state.turnId === turnId ? state : { turnId, callIds: [] as string[] };
const callIds = new Set(current.callIds);

for (const action of actions) {
if (
action.kind !== "subagent-call" ||
action.subagentName !== "agent_builder" ||
callIds.has(action.callId)
) {
continue;
}
if (callIds.size > 0) {
throw new Error(
"The agent builder can be delegated only once per creation turn.",
);
}
callIds.add(action.callId);
}

return { turnId, callIds: [...callIds] };
}
14 changes: 13 additions & 1 deletion apps/agent/agent/lib/builder-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,9 +460,21 @@ async function validateDraft(
.filter((resource) => resource.kind !== "integration")
.map((resource) => `${resource.kind}:${resource.id}`),
);
const taggedRecordLabels = new Map(
taggedResources
.filter((resource) => resource.kind !== "integration")
.map((resource) => [`${resource.kind}:${resource.id}`, resource.label]),
);
for (const resource of recordResources) {
if (!taggedRecordKeys.has(`${resource.kind}:${resource.id}`)) {
const key = `${resource.kind}:${resource.id}`;
if (!taggedRecordKeys.has(key)) {
issues.push(`${resource.label} was not tagged in this builder chat.`);
continue;
}
if (taggedRecordLabels.get(key) !== resource.label) {
issues.push(
`${resource.kind} ${resource.id} must use its exact tagged label.`,
);
}
}

Expand Down
31 changes: 8 additions & 23 deletions apps/agent/agent/subagents/agent_builder/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,15 @@ export default defineAgent({
fallback: DEFAULT_AGENT_MODEL.id,
events: { "session.started": () => selectedModel() },
}),
outputSchema: z.discriminatedUnion("status", [
z.object({
status: z.literal("needs_input"),
question: z.string().min(1).max(500),
options: z
.array(
z.object({
id: z.string().min(1).max(80),
label: z.string().min(1).max(120),
}),
)
.max(4),
allowFreeform: z.boolean(),
}),
z.object({
status: z.literal("draft_ready"),
summary: z.string().min(1).max(1000),
agentId: z.string().min(1),
versionId: z.string().min(1),
}),
]),
outputSchema: z.object({
status: z.literal("draft_ready"),
summary: z.string().min(1).max(1000),
agentId: z.string().min(1),
versionId: z.string().min(1),
}),
limits: {
maxInputTokensPerSession: 250_000,
maxOutputTokensPerSession: 20_000,
maxInputTokensPerSession: 100_000,
maxOutputTokensPerSession: 10_000,
sessionTimeoutMs: 24 * 60 * 60 * 1000,
},
});
29 changes: 29 additions & 0 deletions apps/agent/agent/subagents/agent_builder/hooks/execution-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { defineHook } from "eve/hooks";
import {
builderExecutionState,
markBuilderDraftSaveFinished,
recordBuilderActions,
} from "../lib/execution-state";

export default defineHook({
events: {
"actions.requested"(event) {
const next = recordBuilderActions(
builderExecutionState.get(),
event.data.turnId,
event.data.stepIndex,
event.data.actions,
);
builderExecutionState.update(() => next);
},
"action.result"(event) {
if (
event.data.status !== "completed" &&
event.data.result.kind === "tool-result" &&
event.data.result.toolName === "save_agent_draft"
) {
markBuilderDraftSaveFinished(false);
}
},
},
});
33 changes: 20 additions & 13 deletions apps/agent/agent/subagents/agent_builder/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,23 +31,29 @@ not report.

If no safe and useful draft is possible because an essential target, explicitly
requested connection, schedule, outcome, or side effect remains ambiguous, do
not call `save_agent_draft`. Return `needs_input` with one focused question for
the parent to ask the user. Include two to four mutually exclusive options when
they clarify a real choice, and set `allowFreeform` when a custom answer is
valid. Ask only when the answer materially changes the bounded behavior and the
least-privilege defaults above do not resolve it. Return exactly one decision
per pause; never bundle several missing details into one question. After the
answer, ask the next question only if the build is still materially blocked. Do
not interrupt for a name, wording, optional polish, or another choice that can
be safely represented in the reviewable draft. For a schedule, calculate a
future `nextRunAt` from the supplied current time and provide its recurrence in
minutes.
not call `save_agent_draft`. Call `ask_question` directly with one focused
question. Include two to four mutually exclusive options when they clarify a
real choice, and allow freeform input when a custom answer is valid. Ask only
when the answer materially changes the bounded behavior and the least-privilege
defaults above do not resolve it. Ask exactly one decision per pause; never
bundle several missing details into one question. After the answer, ask the next
question only if the build is still materially blocked. Do not interrupt for a
name, wording, optional polish, or another choice that can be safely represented
in the reviewable draft. For a schedule, calculate a future `nextRunAt` from the
supplied current time and provide its recurrence in minutes.

Choose the record scope explicitly. Use `SELECTED` only for the exact tagged CRM
records reported by `inspect_context`. Use `WORKSPACE` only when the user clearly
asks for workspace-wide CRM access. Never treat an empty selected scope as
workspace access.

The `save_agent_draft` resource contract is exact. Copy only tagged companies,
contacts, and deals from `inspect_context` into `resources`, preserving each
kind, id, and label byte for byte. Put read-only sources in `integrations` using
only `gmail` or `calendar`, and only when `availableConnections` reports that
source. Never put CRM, Gmail, Google Calendar, or another integration in
`resources`. The runtime derives the human-readable access list.

For `crm.activity.create`, list the exact allowed activity types. Authorize
`NOTE`, `TASK`, or both only when the request calls for them. A prose summary
never grants an activity type by itself.
Expand All @@ -60,5 +66,6 @@ call when necessary. Never put credentials, tokens, or secret values in a
file. After the three files agree, call `save_agent_draft` once with the exact
same behavior. A successful save creates exact final file snapshots and an
immutable version in READY state for human review. It does not deploy it.
Return `draft_ready` with the saved agent and version ids plus a plain-language
summary of the trigger, data scope, action, and access.
After a successful save, call no tool except `final_output`. Return
`draft_ready` immediately with the saved agent and version ids plus a
plain-language summary of the trigger, data scope, action, and access.
96 changes: 96 additions & 0 deletions apps/agent/agent/subagents/agent_builder/lib/draft-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { z } from "zod";
import type { DraftAgentInput } from "../../../lib/builder-runtime";

const recordResource = z.object({
kind: z.enum(["company", "contact", "deal"]),
id: z.string().min(1),
label: z.string().min(1).max(120),
});

const trigger = z.object({
type: z.enum(["MANUAL", "SCHEDULE"]),
name: z.string().trim().min(1).max(120),
summary: z.string().trim().min(1).max(240),
nextRunAt: z.string().nullish(),
intervalMinutes: z.number().int().min(1).max(525_600).nullish(),
});

const action = z.discriminatedUnion("type", [
z.object({
type: z.literal("crm.activity.create"),
provider: z.literal("crm"),
summary: z.string().trim().min(1).max(240),
activityTypes: z
.array(z.enum(["NOTE", "TASK"]))
.min(1)
.max(2),
}),
z.object({
type: z.literal("run.summary"),
provider: z.literal("crm"),
summary: z.string().trim().min(1).max(240),
}),
]);

export const builderDraftToolInput = z.object({
name: z.string().trim().min(1).max(100),
description: z.string().trim().min(1).max(320),
instructions: z.string().trim().min(40).max(20_000),
trigger,
recordScope: z.enum(["SELECTED", "WORKSPACE"]),
resources: z.array(recordResource).max(30),
integrations: z.array(z.enum(["gmail", "calendar"])).max(2),
actions: z.array(action).min(1).max(10),
});

type BuilderDraftToolInput = z.infer<typeof builderDraftToolInput>;

const ACTIVITY_ACCESS = {
NOTE: "Write notes on CRM records",
TASK: "Create tasks on CRM records",
} as const;

const ACTIVITY_ORDER = ["NOTE", "TASK"] as const;

const INTEGRATIONS = {
gmail: { kind: "integration", id: "google:gmail", label: "Gmail" },
calendar: {
kind: "integration",
id: "google:calendar",
label: "Google Calendar",
},
} as const;

export function draftInputFromTool(
input: BuilderDraftToolInput,
): DraftAgentInput {
const { integrations: requestedIntegrations, ...draft } = input;
const integrations = [...new Set(requestedIntegrations)];
const activityTypes = new Set(
input.actions.flatMap((entry) =>
entry.type === "crm.activity.create" ? entry.activityTypes : [],
),
);
const access = [
input.recordScope === "WORKSPACE"
? "Read workspace CRM records"
: "Read selected CRM records",
...integrations.map((integration) =>
integration === "gmail"
? "Read connected Gmail messages"
: "Read connected Google Calendar events",
),
...ACTIVITY_ORDER.filter((type) => activityTypes.has(type)).map(
(type) => ACTIVITY_ACCESS[type],
),
];

return {
...draft,
resources: [
...input.resources,
...integrations.map((integration) => INTEGRATIONS[integration]),
],
access,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
};
}
Loading