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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ Co-Authored-By: (agent model name) <email>
- `specs/context-compaction.md` (reusable Pi history compaction, internal context forks, and visible-thread compaction bounds)
- `specs/advisor-tool.md` (provider-agnostic advisor tool contract)
- `specs/scheduler.md` (scheduled Junior task contract)
- `specs/event-prompts.md` (install-owned event prompt bindings and plugin event definition contract)
- `specs/trusted-plugin-heartbeat.md` (trusted plugin heartbeat and tool hook contract)
- `specs/trusted-plugin-dispatch.md` (durable trusted plugin agent dispatch contract)
- `specs/harness-agent.md` (agent loop and output contract)
Expand Down
33 changes: 33 additions & 0 deletions packages/junior-plugin-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,36 @@ export interface AgentPluginCredentialSubject {
allowedWhen: "private-direct-conversation";
}

export interface AgentEventEnvelope {
actor?: {
id?: string;
login?: string;
type?: string;
};
event: string;
occurredAtMs: number;
payload: Record<string, unknown>;
scope: Record<string, unknown>;
sourceEventId: string;
sourceUrl?: string;
}

export interface AgentEventContextBlockRenderContext {
envelope: AgentEventEnvelope;
}

export interface AgentEventContextBlockDefinition {
description: string;
render?(ctx: AgentEventContextBlockRenderContext): Promise<string> | string;
}

export interface AgentEventDefinition {
contextBlocks?: Record<string, AgentEventContextBlockDefinition>;
scopeKeys?: string[];
}

export interface EventRegistrationHookContext extends AgentPluginContext {}

export interface DispatchOptions {
credentialSubject?: AgentPluginCredentialSubject;
destination: {
Expand Down Expand Up @@ -217,6 +247,9 @@ export interface AgentPluginHooks {
tools?(
ctx: ToolRegistrationHookContext,
): Record<string, AgentPluginToolDefinition>;
events?(
ctx: EventRegistrationHookContext,
): Record<string, AgentEventDefinition>;
heartbeat?(
ctx: HeartbeatHookContext,
): Promise<HeartbeatResult | void> | HeartbeatResult | void;
Expand Down
2 changes: 2 additions & 0 deletions packages/junior/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
validateAgentPlugins,
} from "@/chat/plugins/agent-hooks";
import type { PluginCatalogConfig } from "@/chat/plugins/types";
import { loadEventPromptRegistry } from "@/chat/events/registry";
import type {
AgentPluginRouteMethod,
JuniorPluginRegistration,
Expand Down Expand Up @@ -284,6 +285,7 @@ export async function createApp(options?: JuniorAppOptions): Promise<Hono> {
validatePluginRegistrations(configuredPlugins?.registrations ?? []);
}
agentPluginRoutes = getAgentPluginRoutes();
await loadEventPromptRegistry();
} catch (error) {
setPluginCatalogConfig(previousPluginCatalogConfig);
setAgentPlugins(previousAgentPlugins);
Expand Down
83 changes: 83 additions & 0 deletions packages/junior/src/chat/agent-dispatch/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ import {
import type { DispatchCallback, DispatchRecord } from "./types";

const DISPATCH_SLICE_LEASE_MS = 5 * 60 * 1000;
const SILENT_EVENT_SUCCESS_REASON = "silent_event_success";
const EVENT_PROMPT_BLOCKED_TOOL_NAMES = [
"slackCanvasCreate",
"slackCanvasEdit",
"slackCanvasWrite",
"slackChannelPostMessage",
"slackListAddItems",
"slackListCreate",
"slackListUpdateItem",
"slackMessageAddReaction",
"slackScheduleCreateTask",
"slackScheduleDeleteTask",
"slackScheduleListTasks",
"slackScheduleRunTaskNow",
"slackScheduleUpdateTask",
] as const;

export interface AgentDispatchRunnerDeps {
generateAssistantReply?: typeof generateAssistantReplyImpl;
Expand All @@ -70,6 +86,38 @@ function getAssistantMessageId(dispatch: DispatchRecord): string {
return `dispatch:${dispatch.id}:assistant`;
}

function isEventPromptDispatch(dispatch: DispatchRecord): boolean {
return dispatch.runMode === "event_prompt";
}

function isSilentEventSuccess(
dispatch: DispatchRecord,
reply: AssistantReply,
): boolean {
return (
isEventPromptDispatch(dispatch) &&
reply.diagnostics.outcome === "success" &&
reply.text.trim().length === 0 &&
!reply.files?.length
);
}

function hasCompletedSilentEventSuccess(
conversation: ThreadConversationState,
dispatch: DispatchRecord,
): boolean {
if (!isEventPromptDispatch(dispatch)) {
return false;
}
const userMessage = conversation.messages.find(
(message) => message.id === getUserMessageId(dispatch),
);
return (
userMessage?.meta?.replied === false &&
userMessage.meta.skippedReason === SILENT_EVENT_SUCCESS_REASON
);
}

function buildDispatchConversationText(dispatch: DispatchRecord): string {
return `[dispatched task] ${dispatch.input}`;
}
Expand Down Expand Up @@ -237,6 +285,13 @@ export async function runAgentDispatchSlice(

const persisted = await getPersistedThreadState(conversationId);
const conversation = coerceThreadConversationState(persisted);
if (hasCompletedSilentEventSuccess(conversation, dispatch)) {
await markDispatch({
dispatch,
status: "completed",
});
return;
}
const deliveredMessage = conversation.messages.find(
(message) =>
message.id === getAssistantMessageId(dispatch) &&
Expand Down Expand Up @@ -282,6 +337,10 @@ export async function runAgentDispatchSlice(
? { subject: dispatch.credentialSubject }
: {}),
},
allowSilentSuccess: isEventPromptDispatch(dispatch),
...(isEventPromptDispatch(dispatch)
? { blockedToolNames: EVENT_PROMPT_BLOCKED_TOOL_NAMES }
: {}),
configuration,
channelConfiguration,
conversationContext,
Expand Down Expand Up @@ -345,6 +404,30 @@ export async function runAgentDispatchSlice(
});
}

if (isSilentEventSuccess(dispatch, reply)) {
markConversationMessage(conversation, userMessageId, {
replied: false,
skippedReason: SILENT_EVENT_SUCCESS_REASON,
});
updateConversationStats(conversation);
const nextArtifacts = reply.artifactStatePatch
? mergeArtifactsState(artifacts, reply.artifactStatePatch)
: artifacts;
await persistRuntimePatch({
threadId: conversationId,
conversation,
artifacts: nextArtifacts,
sandboxId: reply.sandboxId ?? sandboxId,
sandboxDependencyProfileHash:
reply.sandboxDependencyProfileHash ?? sandboxDependencyProfileHash,
});
await markDispatch({
dispatch,
status: "completed",
});
return;
}

const deliveryReply = ensureVisibleDeliveryText(reply);
const resultMessageTs = await postSlackApiReplyPosts({
channelId: dispatch.destination.channelId,
Expand Down
3 changes: 3 additions & 0 deletions packages/junior/src/chat/agent-dispatch/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
DispatchCreateResult,
DispatchProjection,
DispatchRecord,
DispatchRunMode,
DispatchStatus,
} from "./types";

Expand Down Expand Up @@ -191,6 +192,7 @@ export async function createOrGetDispatch(args: {
nowMs: number;
options: BoundDispatchOptions;
plugin: string;
runMode?: DispatchRunMode;
}): Promise<DispatchCreateResult> {
const id = buildDispatchId(args.plugin, args.options.idempotencyKey);
return await withDispatchLock(id, async (state) => {
Expand All @@ -215,6 +217,7 @@ export async function createOrGetDispatch(args: {
maxAttempts: DEFAULT_MAX_ATTEMPTS,
...(metadata ? { metadata } : {}),
plugin: args.plugin,
runMode: args.runMode ?? "standard",
status: "pending",
updatedAtMs: args.nowMs,
version: 1,
Expand Down
3 changes: 3 additions & 0 deletions packages/junior/src/chat/agent-dispatch/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export type DispatchStatus =

export type DispatchOptions = AgentPluginDispatchOptions;

export type DispatchRunMode = "standard" | "event_prompt";

export type DispatchDestination = DispatchOptions["destination"];

export interface BoundDispatchOptions extends Omit<
Expand All @@ -39,6 +41,7 @@ export interface DispatchRecord {
metadata?: Record<string, string>;
plugin: string;
resultMessageTs?: string;
runMode: DispatchRunMode;
status: DispatchStatus;
updatedAtMs: number;
version: number;
Expand Down
Loading
Loading