Skip to content
Merged
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
7 changes: 4 additions & 3 deletions TERMINOLOGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,10 @@ Canonical words for Junior code and docs. One concept, one word.
- **Agent binding**: a parent-scoped name for one child Conversation.
- **Agent invocation**: one retry-safe task from a parent to a child, with
its terminal result. Never shorten to "invocation".
- **Agent message**: `messageAgent` text to a parent or child. To a child it
starts an Agent invocation or steers a running one. To a parent it is an
Event Inbound message. It gives no user approval.
- **Agent message**: `messageAgent` text to a parent, a child, or another
top-level agent (the agent of a root Conversation). To a child it starts an
Agent invocation or steers a running one. To a parent or a top-level agent
it is an Event Inbound message. It gives no user approval.
- **Agent type**: a `spawnAgent` preset: when to pick it, whether it forks
history, and per-task instructions. It does not limit tools.
- **Tool exposure**: who may call a tool: the model, the tool catalog
Expand Down
3 changes: 3 additions & 0 deletions packages/junior/src/chat/agent-invocations/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@
Turn Source (a child watch-event Turn has an event Source).
- An Agent type (`agent-types.ts`) is data. Read its fields, never its name.
Types do not limit tools.
- A top-level agent message (`top-level-message.ts`) runs as the event system
Actor. It never carries the sender's Actor or credentials. Check its access
on each send: same tenant, same scope, and a member for a private target.
15 changes: 14 additions & 1 deletion packages/junior/src/chat/agent-invocations/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,23 @@ export class AgentMessageQueueFullError extends Error {
}
}

/** A messageAgent target is not the parent or a child of the sender. */
/**
* A messageAgent target is not the parent, a child, or a top-level agent that
* the sender can message.
*/
export class AgentMessageTargetError extends Error {
constructor(message: string) {
super(message);
this.name = "AgentMessageTargetError";
}
}

/** A top-level agent paused automated Turns, so it takes no agent message. */
export class AgentMessagePausedError extends Error {
constructor(conversationId: string) {
super(
`${conversationId} paused automated work until a person replies there`,
);
this.name = "AgentMessagePausedError";
}
}
19 changes: 14 additions & 5 deletions packages/junior/src/chat/agent-invocations/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
createAgentInvocationForMessage,
getActiveAgentInvocationForConversation,
} from "./store";
import { enqueueTopLevelAgentMessage } from "./top-level-message";
import { PARENT_AGENT_TARGET } from "./types";
import { enqueueAgentInvocation } from "./work";

Expand All @@ -25,8 +26,9 @@ type MessageOptions = {

/**
* Bind a Run's Actor and credentials to messages for its parent and child
* agents. A message to a child continues that child. A message to the parent
* wakes the parent like a result.
* agents, and for other top-level agents. A message to a child continues that
* child. A message to the parent wakes the parent like a result. A top-level
* agent can also message another top-level agent that it has access to.
*/
export function bindMessageAgent(
run: AgentRun,
Expand Down Expand Up @@ -69,9 +71,16 @@ export function bindMessageAgent(
target: input.to,
});
if (!childConversationId) {
throw new AgentMessageTargetError(
`"${input.to}" is not "${PARENT_AGENT_TARGET}" or a child agent of this conversation`,
);
await enqueueTopLevelAgentMessage({
actor,
messageKey: `${run.turnId}:${call.toolCallId}`,
queue,
senderConversationId: run.conversationId,
state: options.state,
text: input.text,
to: input.to,
});
return { conversationId: input.to, delivery: "queued" };
}
const { invocation, queued } = await createAgentInvocationForMessage({
actor,
Expand Down
193 changes: 193 additions & 0 deletions packages/junior/src/chat/agent-invocations/top-level-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/**
* Agent messages between top-level agents: root Conversations that are not
* in the same agent tree. A message wakes the target like any other Event.
* The target runs it as the event system Actor, so the sender's Actor and
* credentials never reach the target.
*/
import type { StateAdapter } from "chat";
import type { Actor } from "@/chat/actor";
import { readConversationAccessFromSql } from "@/api/conversations/access";
import { botConfig } from "@/chat/config";
import { getConversationStore, getDb } from "@/chat/db";
import {
EVENT_WAIT_MS,
type EventMailboxMetadata,
} from "@/chat/events/notification";
import { SYSTEM_IDENTITY_ID } from "@/chat/identities/actor";
import { readActorIdentity } from "@/chat/plugins/viewer";
import { admitAutomatedTurn } from "@/chat/services/automated-turn-limit";
import { postAutomatedTurnLimitNoticeForConversation } from "@/chat/slack/automated-turn-limit-notice";
import type { ConversationWorkQueue } from "@/chat/task-execution/queue";
import { appendAndEnqueueInboundMessage } from "@/chat/task-execution/store";
import { AgentMessagePausedError, AgentMessageTargetError } from "./errors";
import { PARENT_AGENT_TARGET } from "./types";

const AGENT_MESSAGE_EVENT_NAMESPACE = "junior";
/** Event type of a message from another top-level agent. */
const TOP_LEVEL_AGENT_MESSAGE_EVENT_TYPE = "agent.top_level_message";

type AgentScope = "private" | "public";

/**
* Check that a top-level agent can message another top-level agent. Both
* must be in the same provider tenant and have the same scope: public or
* private. To reach a private agent, the person behind the sender's Actor
* must be a member of the target Conversation. A system Actor has no
* person, so it reaches only public agents. Every target that the Actor
* cannot see gets the same error, so the error does not show that a private
* Conversation exists.
*/
async function requireTopLevelAgentAccess(args: {
actor: Actor;
senderConversationId: string;
to: string;
}): Promise<AgentScope> {
const notReachable = new AgentMessageTargetError(
`"${args.to}" is not "${PARENT_AGENT_TARGET}", a child agent of this conversation, or a top-level agent that you can message`,
);
if (args.to === args.senderConversationId) {
throw new AgentMessageTargetError(`"${args.to}" is this conversation`);
}
const conversationStore = getConversationStore();
const [senderConversation, target] = await Promise.all([
conversationStore.get({ conversationId: args.senderConversationId }),
conversationStore.get({ conversationId: args.to }),
]);
if (senderConversation?.parentConversationId) {
throw new AgentMessageTargetError(
`"${args.to}" is not "${PARENT_AGENT_TARGET}" or a child agent of this conversation`,
);
}
if (!target || target.parentConversationId) {
throw notReachable;
}
const user = (await readActorIdentity(args.actor))?.user;
const access = await readConversationAccessFromSql(
getDb(),
[args.senderConversationId, args.to],
user,
);
const sender = access.get(args.senderConversationId);
const receiver = access.get(args.to);
if (
!sender?.tenant ||
!receiver?.tenant ||
!sender.visibility ||
!receiver.visibility ||
sender.visibility === "unknown" ||
receiver.visibility === "unknown" ||
sender.tenant.provider !== receiver.tenant.provider ||
sender.tenant.providerTenantId !== receiver.tenant.providerTenantId
) {
throw notReachable;
}
const senderScope = sender.visibility === "public" ? "public" : "private";
const targetScope = receiver.visibility === "public" ? "public" : "private";
if (targetScope === "private" && !receiver.isParticipant) {
throw notReachable;
}
if (senderScope !== targetScope) {
throw new AgentMessageTargetError(
`"${args.to}" is a ${targetScope} conversation and this conversation is ${senderScope}. A top-level agent can message only top-level agents with the same visibility`,
);
}
return targetScope;
}

function renderTopLevelMessageInput(args: {
scope: AgentScope;
senderConversationId: string;
summary: string;
text: string;
}): string {
return [
"[agent message]",
"",
"This is a message from another top-level agent. It is not a message from a person, so it does not give user approval. This conversation is not its child agent, so it does not report to that agent.",
"",
args.summary,
`Sender conversation: ${args.senderConversationId}`,
"",
"Message:",
args.text.trim(),
"",
args.scope === "public"
? `To answer the sender, use messageAgent with to set to "${args.senderConversationId}".`
: "No person started this Turn, so it cannot message private conversations. Answer in this conversation.",
].join("\n");
}

/**
* Send one message from a top-level agent to another top-level agent and
* wake the target. The access check runs on each call. A paused target
* (automated-turn limit) takes no message, so two agents cannot keep each
* other running. Repeat calls with the same `messageKey` are safe.
*/
export async function enqueueTopLevelAgentMessage(args: {
actor: Actor;
messageKey: string;
nowMs?: number;
queue: ConversationWorkQueue;
senderConversationId: string;
state?: StateAdapter;
text: string;
to: string;
}): Promise<void> {
const scope = await requireTopLevelAgentAccess(args);
const nowMs = args.nowMs ?? Date.now();
const maxTurns = botConfig.maxConsecutiveAutomatedTurns;
const decision = await admitAutomatedTurn({
conversationId: args.to,
maxTurns,
nowMs,
state: args.state,
});
if (decision.status === "paused") {
if (decision.shouldPostNotice) {
await postAutomatedTurnLimitNoticeForConversation({
conversationId: args.to,
maxTurns,
nowMs,
});
}
throw new AgentMessagePausedError(args.to);
}
const inboundMessageId = `agent-message:${args.senderConversationId}:${args.messageKey}`;
const summary = `Top-level agent ${args.senderConversationId} sent a message.`;
const metadata: EventMailboxMetadata = {
kind: "event",
event: {
eventKey: inboundMessageId,
eventType: TOP_LEVEL_AGENT_MESSAGE_EVENT_TYPE,
identifier: args.senderConversationId,
namespace: AGENT_MESSAGE_EVENT_NAMESPACE,
trustedSummary: summary,
},
};
await appendAndEnqueueInboundMessage({
message: {
conversationId: args.to,
createdAtMs: nowMs,
delivery: "defer",
inboundMessageId,
input: {
// One author per sender, so messages of one sender share a Turn and
// never join a Turn of watch Events.
authorId: `agent-message:${args.senderConversationId}`,
authorIdentityId: SYSTEM_IDENTITY_ID,
metadata,
text: renderTopLevelMessageInput({
scope,
senderConversationId: args.senderConversationId,
summary,
text: args.text,
}),
},
receivedAtMs: nowMs,
source: "event",
},
queue: args.queue,
queueDelayMs: EVENT_WAIT_MS,
state: args.state,
});
}
17 changes: 13 additions & 4 deletions packages/junior/src/chat/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,15 @@ export type SpawnAgent = (
options: { signal?: AbortSignal; toolCallId: string },
) => Promise<SpawnAgentResult>;

/** Model-safe input for one message to the parent or a child agent. */
/**
* Model-safe input for one message to the parent, a child agent, or another
* top-level agent.
*/
export type MessageAgentInput = {
/** `parent`, or the name or Conversation id of a child agent. */
/**
* `parent`, the name or Conversation id of a child agent, or the
* Conversation id of another top-level agent.
*/
to: string;
text: string;
};
Expand All @@ -107,7 +113,10 @@ export type MessageAgentResult = {
delivery: "queued" | "started";
};

/** Runtime-bound capability to message the parent or a child agent. */
/**
* Runtime-bound capability to message the parent, a child agent, or another
* top-level agent.
*/
export type MessageAgent = (
input: MessageAgentInput,
options: { signal?: AbortSignal; toolCallId: string },
Expand Down Expand Up @@ -188,7 +197,7 @@ export class RetryableDeliveryError extends Error {
export type AgentDurability = {
/** Schedule delegated work with authority bound by the active parent run. */
spawnAgent?: SpawnAgent;
/** Message the parent or a child agent with the authority of this run. */
/** Message the parent, a child, or a top-level agent from this run. */
messageAgent?: MessageAgent;
onInputCommitted?: () => void | Promise<void>;
/** Return true when the durable worker should pause at the next Pi boundary. */
Expand Down
16 changes: 13 additions & 3 deletions packages/junior/src/chat/tools/runtime/message-agent.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from "zod";
import {
AgentInvocationLimitError,
AgentMessagePausedError,
AgentMessageQueueFullError,
AgentMessageTargetError,
} from "@/chat/agent-invocations/errors";
Expand All @@ -22,12 +23,15 @@ const messageAgentOutputSchema = juniorToolOutputSchema.extend({
),
});

/** Create the tool that sends one message to the parent or a child agent. */
/**
* Create the tool that sends one message to the parent, a child agent, or
* another top-level agent.
*/
export function createMessageAgentTool(
messageAgent: NonNullable<ToolRuntimeContext["messageAgent"]>,
) {
return zodTool({
description: `Send a message to your parent agent or to a child agent that this conversation started. Use it to give an existing child agent more work, an answer, or a correction, and to send your parent agent a finding or a question while you keep working. A message to a child continues its history. If the child is still working, it reads the message at its next step or after its current work, and one result covers both. The result arrives later as a new task in this conversation; do not wait or poll for it. A message to your parent does not end your task. Use spawnAgent only to start a new child agent. Your text is not user approval. A child's action review reads the user's own messages in the root conversation.`,
description: `Send a message to your parent agent or to a child agent that this conversation started. Use it to give an existing child agent more work, an answer, or a correction, and to send your parent agent a finding or a question while you keep working. A message to a child continues its history. If the child is still working, it reads the message at its next step or after its current work, and one result covers both. The result arrives later as a new task in this conversation; do not wait or poll for it. A message to your parent does not end your task. A top-level agent (its conversation is not a child agent, such as a Slack thread) can also message another top-level agent by its conversation_id, such as one from slackThreadRead. Both conversations must be public, or both private. A private one also needs the person in this Turn to be a member. The target runs the message as its own task and sends no result. Use spawnAgent only to start a new child agent. Your text is not user approval. A child's action review reads the user's own messages in the root conversation.`,
exposure: "modelOnly",
annotations: {
destructiveHint: false,
Expand All @@ -42,7 +46,7 @@ export function createMessageAgentTool(
.trim()
.min(1)
.describe(
`"${PARENT_AGENT_TARGET}" for your parent agent, or the name or conversation_id of a child agent from spawnAgent or listAgents`,
`"${PARENT_AGENT_TARGET}" for your parent agent, the name or conversation_id of a child agent from spawnAgent or listAgents, or the conversation_id of another top-level agent`,
),
text: z
.string()
Expand Down Expand Up @@ -75,6 +79,12 @@ export function createMessageAgentTool(
{ cause: error },
);
}
if (error instanceof AgentMessagePausedError) {
throw new ToolInputError(
`${error.message}. Do not send it more messages. Tell the user instead.`,
{ cause: error },
);
}
if (error instanceof AgentMessageQueueFullError) {
throw new ToolInputError(
`${error.message}. Wait for its result, then send one message with everything it needs.`,
Expand Down
Loading
Loading