Skip to content
Draft
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: 7 additions & 0 deletions packages/docs/src/content/docs/reference/config-and-env.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ related:
| `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET` | Conditional | Bearer token for the internal heartbeat route; use `CRON_SECRET` with Vercel Cron, or `JUNIOR_SCHEDULER_SECRET` for a non-Vercel heartbeat caller. |
| `JUNIOR_TIMEZONE` | No | Default IANA timezone for scheduler authoring when the scheduler plugin is enabled. Defaults to `America/Los_Angeles`. |
| `AI_GATEWAY_API_KEY` | No | Fallback AI Gateway auth when Vercel OIDC is unavailable (local/CI/non-Vercel hosts). On Vercel, prefer project OIDC so usage attributes to the project. |
| `TYPESAFE_API_KEY` | No | Use TypeSafe JEV for the experimental passive Slack reply classifier. This has no effect unless `passive-routing` is enabled. |
| `BLOB_STORE_ID` | Conditional | Vercel Blob store for durable conversation attachments and published public artifacts. Vercel sets this when an OIDC-enabled Blob store is connected to the project. |
| `BLOB_READ_WRITE_TOKEN` | Conditional | Static Vercel Blob credential when OIDC is unavailable. Vercel sets this for a token-connected store. |

Expand Down Expand Up @@ -166,6 +167,12 @@ const app = await createApp({
});
```

`passive-routing` applies only to Slack threads that Junior has already joined.
It does not make Junior process every message in a channel. If `TYPESAFE_API_KEY`
is set, Junior uses TypeSafe JEV for the passive reply decision. It uses a
conservative reply threshold and stays quiet if the provider request fails.
Without this key, Junior uses the configured fast model.

`junior chat` enables experimental `subagents` automatically because it is the
local createApp-equivalent entrypoint and already wires the child-worker path.

Expand Down
7 changes: 7 additions & 0 deletions packages/junior/src/chat/app/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type SubscribedReplyPolicy,
type SubscribedReplyPolicyDeps,
} from "@/chat/services/subscribed-reply-policy";
import { createJevSubscribedReplyClassifier } from "@/chat/services/jev-subscribed-reply-classifier";
import {
createVisionContextService,
type VisionContextDeps,
Expand Down Expand Up @@ -53,6 +54,7 @@ export interface JuniorRuntimeServiceOverrides {
export function createJuniorRuntimeServices(
overrides: JuniorRuntimeServiceOverrides = {},
): JuniorRuntimeServices {
const typesafeApiKey = process.env.TYPESAFE_API_KEY?.trim();
const conversationMemory = createConversationMemoryService({
completeText: overrides.conversationMemory?.completeText ?? completeText,
});
Expand Down Expand Up @@ -83,6 +85,11 @@ export function createJuniorRuntimeServices(
executeTurn: async (run, saveResult, timeoutMs) =>
await executeTurn(agentRunner, run, saveResult, timeoutMs),
subscribedReplyPolicy: createSubscribedReplyPolicy({
classifyReply:
overrides.subscribedReplyPolicy?.classifyReply ??
(typesafeApiKey
? createJevSubscribedReplyClassifier({ apiKey: typesafeApiKey })
: undefined),
completeObject:
overrides.subscribedReplyPolicy?.completeObject ?? completeObject,
}),
Expand Down
116 changes: 116 additions & 0 deletions packages/junior/src/chat/services/jev-subscribed-reply-classifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { z } from "zod";
import type {
RouterEvidence,
SubscribedReplyClassification,
} from "@/chat/services/subscribed-decision";

const TYPESAFE_SYSTEM_ONE_URL = "https://api.typesafe.ai/v1/systemone";
const JEV_MODEL = "jev-latest";

const jevResponseSchema = z
.object({
answers: z
.object({
should_reply: z
.object({ type: z.literal("noul"), noul: z.number().min(0).max(1) })
.strict(),
should_unsubscribe: z
.object({ type: z.literal("noul"), noul: z.number().min(0).max(1) })
.strict(),
})
.strict(),
})
.passthrough();

const REPLY_THRESHOLD = 0.8;
const UNSUBSCRIBE_THRESHOLD = 0.9;
const REQUEST_TIMEOUT_MS = 2_000;

export interface JevSubscribedReplyClassifierOptions {
apiKey: string;
fetch?: typeof fetch;
}

/** Create a JEV classifier for passive replies in subscribed Slack threads. */
export function createJevSubscribedReplyClassifier(
options: JevSubscribedReplyClassifierOptions,
): (args: {
botUserName: string;
evidence: RouterEvidence;
latestMessage: string;
}) => Promise<SubscribedReplyClassification> {
const fetchImpl = options.fetch ?? globalThis.fetch;

return async ({ botUserName, evidence, latestMessage }) => {
const response = await fetchImpl(TYPESAFE_SYSTEM_ONE_URL, {
method: "POST",
headers: {
authorization: `Bearer ${options.apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify({
model: JEV_MODEL,
state: {
assistant_name: botUserName,
transcript: evidence.entries,
signals: {
assistant_was_last_speaker: evidence.assistantWasLastSpeaker,
current_message_has_attachments:
evidence.currentMessageHasAttachments,
current_message_has_directed_follow_up_cue:
evidence.currentMessageHasDirectedFollowUpCue,
current_message_is_terse_clarification:
evidence.currentMessageIsTerseClarification,
human_messages_since_last_assistant:
evidence.humanMessagesSinceLastAssistant ?? null,
latest_prior_message_role: evidence.latestPriorMessageRole,
},
latest_prior_assistant_message: evidence.latestPriorAssistantMessage,
latest_message: latestMessage,
},
questions: {
should_reply: {
type: "noul",
instructions:
"Should the assistant reply to the latest message in this Slack thread?",
criteria: {
true: "The latest message asks the assistant to act, answer, clarify, or continue its work.",
false:
"The message is side conversation, an acknowledgment, or directed to another person.",
},
},
should_unsubscribe: {
type: "noul",
instructions:
"Does the latest message clearly ask the assistant to stop participating in this thread?",
criteria: {
true: "The user clearly asks the assistant to leave or stop replying in this thread.",
false: "The user does not clearly opt out of assistant replies.",
},
},
},
}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});

if (!response.ok) {
throw new Error(
`TypeSafe System One request failed (${response.status})`,
);
}

const result = jevResponseSchema.parse(await response.json());
const replyProbability = result.answers.should_reply.noul;
const unsubscribeProbability = result.answers.should_unsubscribe.noul;

const shouldUnsubscribe = unsubscribeProbability >= UNSUBSCRIBE_THRESHOLD;
return {
shouldReply: replyProbability >= REPLY_THRESHOLD,
shouldUnsubscribe,
confidence: shouldUnsubscribe
? Math.max(unsubscribeProbability, 1 - unsubscribeProbability)
: Math.max(replyProbability, 1 - replyProbability),
reason: `jev reply=${replyProbability.toFixed(2)} unsubscribe=${unsubscribeProbability.toFixed(2)}`,
};
};
}
127 changes: 80 additions & 47 deletions packages/junior/src/chat/services/subscribed-decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,67 @@ export interface SubscribedDecisionResult {
reasonDetail?: string;
}

export interface SubscribedReplyClassification {
confidence: number;
reason: string;
shouldReply: boolean;
shouldUnsubscribe: boolean;
}

function mapSubscribedReplyClassification(
classification: SubscribedReplyClassification,
costUsd?: number,
): SubscribedDecisionResult {
const cost = costUsd === undefined ? {} : { costUsd };
const reason = classification.reason.trim() || "classifier";
if (classification.shouldUnsubscribe) {
if (classification.confidence < ROUTER_CONFIDENCE_THRESHOLD) {
return {
...cost,
shouldReply: false,
reason: SubscribedReplyReason.LowConfidence,
reasonDetail: `${classification.confidence.toFixed(2)}: ${reason}`,
};
}
return {
...cost,
shouldReply: false,
shouldUnsubscribe: true,
reason: SubscribedReplyReason.ThreadOptOut,
reasonDetail: reason,
};
}
if (!classification.shouldReply) {
return {
...cost,
shouldReply: false,
reason: SubscribedReplyReason.SideConversation,
reasonDetail: reason,
};
}
if (classification.confidence < ROUTER_CONFIDENCE_THRESHOLD) {
return {
...cost,
shouldReply: false,
reason: SubscribedReplyReason.LowConfidence,
reasonDetail: `${classification.confidence.toFixed(2)}: ${reason}`,
};
}
return {
...cost,
shouldReply: true,
reason: SubscribedReplyReason.Classifier,
reasonDetail: reason,
};
}

interface TranscriptMessage {
author: string;
role: "assistant" | "user";
text: string;
}

interface RouterEvidence {
export interface RouterEvidence {
assistantWasLastSpeaker: boolean;
currentMessageHasAttachments: boolean;
currentMessageHasDirectedFollowUpCue: boolean;
Expand Down Expand Up @@ -90,8 +144,7 @@ const TRANSCRIPT_MESSAGE_LINE_RE =
/** `!stop` may appear anywhere in the message. */
const BANG_STOP_RE = /(?:^|\s)!stop(?=\s|$|[.!?,;:])/i;
/** Drop a leading `@jr` / mention before matching bare `stop`. */
const LEADING_ADDRESS_RE =
/^(?:(?:<@[^>]+>|@[\w.-]+)\s*[,:\-–—]?\s*)+/i;
const LEADING_ADDRESS_RE = /^(?:(?:<@[^>]+>|@[\w.-]+)\s*[,:\-–—]?\s*)+/i;
/** Whole message is only `stop` after any leading address. */
const BARE_STOP_RE = /^stop(?:\s*[.!…]+)?$/i;
const ACKNOWLEDGMENT_ONLY_RE =
Expand Down Expand Up @@ -434,6 +487,11 @@ export async function decideSubscribedThreadReply(args: {
botUserName: string;
modelId: string;
input: SubscribedDecisionInput;
classifyReply?: (args: {
botUserName: string;
evidence: RouterEvidence;
latestMessage: string;
}) => Promise<SubscribedReplyClassification>;
completeObject: (args: {
modelId: string;
schema: typeof replyDecisionSchema;
Expand Down Expand Up @@ -510,6 +568,16 @@ export async function decideSubscribedThreadReply(args: {
}

try {
if (args.classifyReply) {
return mapSubscribedReplyClassification(
await args.classifyReply({
botUserName: args.botUserName,
evidence,
latestMessage: rawText.trim() || "[attachment-only message]",
}),
);
}

const result = await args.completeObject({
modelId: args.modelId,
schema: replyDecisionSchema,
Expand All @@ -528,50 +596,15 @@ export async function decideSubscribedThreadReply(args: {
});

const parsed = replyDecisionSchema.parse(result.object);
const reason = parsed.reason?.trim() || "classifier";
if (parsed.should_unsubscribe) {
if (parsed.confidence < ROUTER_CONFIDENCE_THRESHOLD) {
return {
...(result.costUsd !== undefined ? { costUsd: result.costUsd } : undefined),
shouldReply: false,
reason: SubscribedReplyReason.LowConfidence,
reasonDetail: `${parsed.confidence.toFixed(2)}: ${reason}`,
};
}

return {
...(result.costUsd !== undefined ? { costUsd: result.costUsd } : undefined),
shouldReply: false,
shouldUnsubscribe: true,
reason: SubscribedReplyReason.ThreadOptOut,
reasonDetail: reason,
};
}

if (!parsed.should_reply) {
return {
...(result.costUsd !== undefined ? { costUsd: result.costUsd } : undefined),
shouldReply: false,
reason: SubscribedReplyReason.SideConversation,
reasonDetail: reason,
};
}

if (parsed.confidence < ROUTER_CONFIDENCE_THRESHOLD) {
return {
...(result.costUsd !== undefined ? { costUsd: result.costUsd } : undefined),
shouldReply: false,
reason: SubscribedReplyReason.LowConfidence,
reasonDetail: `${parsed.confidence.toFixed(2)}: ${reason}`,
};
}

return {
...(result.costUsd !== undefined ? { costUsd: result.costUsd } : undefined),
shouldReply: true,
reason: SubscribedReplyReason.Classifier,
reasonDetail: reason,
};
return mapSubscribedReplyClassification(
{
confidence: parsed.confidence,
reason: parsed.reason,
shouldReply: parsed.should_reply,
shouldUnsubscribe: parsed.should_unsubscribe,
},
result.costUsd,
);
} catch (error) {
if (isProviderRetryError(error)) {
throw error;
Expand Down
12 changes: 11 additions & 1 deletion packages/junior/src/chat/services/subscribed-reply-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@ import { logWarn } from "@/chat/logging";
import {
decideSubscribedThreadReply,
type SubscribedDecisionInput,
type SubscribedReplyClassification,
type RouterEvidence,
} from "@/chat/services/subscribed-decision";
import type { completeObject } from "@/chat/pi/client";

export interface SubscribedReplyPolicyDeps {
classifyReply?: (args: {
botUserName: string;
evidence: RouterEvidence;
latestMessage: string;
}) => Promise<SubscribedReplyClassification>;
completeObject: typeof completeObject;
}

Expand All @@ -29,6 +36,7 @@ export function createSubscribedReplyPolicy(
botUserName: botConfig.userName,
modelId: botConfig.fastModelId,
input: args,
classifyReply: deps.classifyReply,
completeObject: deps.completeObject,
logClassifierFailure: (error) => {
logWarn("subscribed_message.classifier.failed", {
Expand All @@ -42,7 +50,9 @@ export function createSubscribedReplyPolicy(
? `${decision.reason}:${decision.reasonDetail}`
: decision.reason;
return {
...(decision.costUsd !== undefined ? { costUsd: decision.costUsd } : undefined),
...(decision.costUsd !== undefined
? { costUsd: decision.costUsd }
: undefined),
shouldReply: decision.shouldReply,
shouldUnsubscribe: decision.shouldUnsubscribe,
reason,
Expand Down
Loading
Loading