Skip to content
Open
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ Co-Authored-By: (agent model name) <email>
- `specs/oauth-flows-spec.md` (OAuth authorization code flow + Slack UX contract)
- `specs/agent-prompt-spec.md` (core prompt ownership, execution-bias, and bloat-control contract)
- `specs/advisor-tool-spec.md` (draft provider-agnostic advisor tool contract)
- `specs/scheduler-spec.md` (draft scheduled Junior task contract)
- `specs/harness-agent-spec.md` (agent loop and output contract)
- `specs/agent-session-resumability-spec.md` (multi-slice turn resumability and timeout recovery contract)
- `specs/agent-execution-spec.md` (agent execution rubric and completion gates)
Expand Down
25 changes: 13 additions & 12 deletions packages/docs/src/content/docs/reference/config-and-env.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,19 @@ related:

## Core runtime

| Variable | Required | Purpose |
| ------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SLACK_SIGNING_SECRET` | Yes | Verifies Slack request signatures. |
| `SLACK_BOT_TOKEN` or `SLACK_BOT_USER_TOKEN` | Yes | Posts thread replies and calls Slack APIs. |
| `REDIS_URL` | Yes | Queue and runtime state storage. |
| `JUNIOR_BOT_NAME` | No | Bot display/config naming. |
| `AI_MODEL` | No | Primary model selection override for main assistant turns. Defaults to `openai/gpt-5.4`; Junior chooses the reasoning effort per turn automatically. |
| `AI_FAST_MODEL` | No | Faster model for lightweight tasks and routing/classification passes before the main turn begins. Defaults to `openai/gpt-5.4-mini`. |
| `AI_VISION_MODEL` | No | Dedicated image-understanding model; unset disables vision features. |
| `AI_WEB_SEARCH_MODEL` | No | Override for the `webSearch` tool model. Defaults to a search-tuned model; does not fall through to `AI_MODEL`. |
| `JUNIOR_BASE_URL` | No | Canonical base URL for callback/auth URL generation. |
| `AI_GATEWAY_API_KEY` | No | AI gateway auth if used in your setup. |
| Variable | Required | Purpose |
| ------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SLACK_SIGNING_SECRET` | Yes | Verifies Slack request signatures. |
| `SLACK_BOT_TOKEN` or `SLACK_BOT_USER_TOKEN` | Yes | Posts thread replies and calls Slack APIs. |
| `REDIS_URL` | Yes | Queue and runtime state storage. |
| `JUNIOR_BOT_NAME` | No | Bot display/config naming. |
| `AI_MODEL` | No | Primary model selection override for main assistant turns. Defaults to `openai/gpt-5.4`; Junior chooses the reasoning effort per turn automatically. |
| `AI_FAST_MODEL` | No | Faster model for lightweight tasks and routing/classification passes before the main turn begins. Defaults to `openai/gpt-5.4-mini`. |
| `AI_VISION_MODEL` | No | Dedicated image-understanding model; unset disables vision features. |
| `AI_WEB_SEARCH_MODEL` | No | Override for the `webSearch` tool model. Defaults to a search-tuned model; does not fall through to `AI_MODEL`. |
| `JUNIOR_BASE_URL` | No | Canonical base URL for callback/auth URL generation. |
| `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET` | Conditional | Bearer token for `/api/internal/scheduler/tick`; use `CRON_SECRET` with Vercel Cron, or `JUNIOR_SCHEDULER_SECRET` for an external scheduler. |
| `AI_GATEWAY_API_KEY` | No | AI gateway auth if used in your setup. |

## Build-time snapshot warmup

Expand Down
20 changes: 20 additions & 0 deletions packages/junior-evals/evals/behavior-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export interface EvalCanvasArtifact {
}

export interface EvalToolInvocation {
arguments?: Record<string, unknown>;
tool: string;
bash_command?: string;
mcp_arguments?: Record<string, unknown>;
Expand Down Expand Up @@ -216,6 +217,24 @@ function toEvalToolInvocation(input: {
}): EvalToolInvocation {
const invocation: EvalToolInvocation = { tool: input.toolName };

if (input.toolName.startsWith("slackSchedule")) {
invocation.arguments = Object.fromEntries(
[
"title",
"objective",
"schedule_description",
"timezone",
"next_run_at_iso",
"recurrence_frequency",
"recurrence_interval",
"recurrence_weekdays",
"status",
]
.filter((key) => key in input.params)
.map((key) => [key, input.params[key]]),
);
}

if (input.toolName === "bash" && typeof input.params.command === "string") {
invocation.bash_command = input.params.command.trim();
}
Expand Down Expand Up @@ -598,6 +617,7 @@ function toIncomingMessage(event: MentionEvent | SubscribedMessageEvent) {
runId: event.thread.run_id,
raw: {
channel: event.thread.channel_id,
team_id: "T_EVAL",
ts: messageTs,
thread_ts: event.thread.thread_ts,
},
Expand Down
73 changes: 73 additions & 0 deletions packages/junior-evals/evals/core/scheduler.eval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describeEval } from "vitest-evals";
import { mention, rubric, slackEvals } from "../helpers";

describeEval("Scheduler", slackEvals, (it) => {
it("when asked to schedule recurring work, draft the task for confirmation before creating it", async ({
run,
}) => {
await run({
events: [
mention(
"@bot schedule this every Monday at 9am Pacific: check open GitHub issues about the scheduler and post a short digest here.",
),
],
criteria: rubric({
contract:
"A future or recurring task request is normalized into a scheduled task draft for the active Slack context before it is persisted.",
pass: [
"observed_tool_invocations does not contain slackScheduleCreateTask unless the tool input includes confirmed_by_user true.",
"The draft task title/objective/instructions describe checking scheduler-related GitHub issues, not creating a schedule.",
"The reply asks the user to confirm the normalized cadence or next run before creating the schedule.",
],
fail: [
"Do not persist a scheduled task before user confirmation.",
"Do not ask the user to provide a channel ID.",
"Do not use Slack chat.scheduleMessage.",
"Do not only give instructions for how the user can set up an external cron.",
],
}),
});
});

it("when executing a scheduled-task prompt, perform the task instead of creating another schedule", async ({
run,
}) => {
await run({
events: [
mention(`@bot <scheduled-task-run>
This is an autonomous scheduled run. Treat the stored task contract as the user request for this turn.

<scheduled-task>
- id: sched_eval
- title: Weekly scheduler digest
- objective: Summarize open scheduler issues.
<instructions>
- Check for open scheduler issues.
- Post a concise digest.
</instructions>
</scheduled-task>

<execution-rules>
- Execute the scheduled task described in <scheduled-task>; do not create, update, pause, delete, or list schedules.
</execution-rules>

<current-instruction priority="highest">
Execute the scheduled task now and provide the final result for the configured destination.
</current-instruction>
</scheduled-task-run>`),
],
criteria: rubric({
contract:
"A scheduled-task execution prompt is treated as the task to run, not as a request to schedule something.",
pass: [
"observed_tool_invocations does not contain slackScheduleCreateTask.",
"The assistant attempts to produce or explain a scheduler issue digest.",
],
fail: [
"Do not create, edit, delete, or list scheduled tasks.",
"Do not say the task has been scheduled.",
],
}),
});
});
});
3 changes: 3 additions & 0 deletions packages/junior-evals/evals/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ function toToolCallRecord(
invocation: EvalResult["toolInvocations"][number],
): ToolCallRecord {
const args: Record<string, JsonValue> = {};
if (invocation.arguments) {
args.arguments = toJson(invocation.arguments);
}
if (invocation.bash_command) {
args.command = invocation.bash_command;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/junior/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { GET as dashboardGET } from "@/handlers/diagnostics-dashboard";
import { GET as healthGET } from "@/handlers/health";
import { GET as mcpOauthCallbackGET } from "@/handlers/mcp-oauth-callback";
import { GET as oauthCallbackGET } from "@/handlers/oauth-callback";
import { ALL as schedulerTickALL } from "@/handlers/scheduler-tick";
import {
ALL as sandboxEgressProxyALL,
isSandboxEgressRequest,
Expand Down Expand Up @@ -103,6 +104,10 @@ export async function createApp(options?: JuniorAppOptions): Promise<Hono> {
return turnResumePOST(c.req.raw, waitUntil);
});

app.all("/api/internal/scheduler/tick", (c) => {
return schedulerTickALL(c.req.raw, waitUntil);
});

app.post("/api/webhooks/:platform", (c) => {
return webhooksPOST(c.req.raw, c.req.param("platform"), waitUntil);
});
Expand Down
16 changes: 3 additions & 13 deletions packages/junior/src/chat/ingress/workspace-membership.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
import { AsyncLocalStorage } from "node:async_hooks";

const workspaceTeamIdStorage = new AsyncLocalStorage<string>();

/** Run a callback with the workspace team ID available for membership checks. */
export function runWithWorkspaceTeamId<T>(
teamId: string | undefined,
fn: () => T,
): T {
if (!teamId) return fn();
return workspaceTeamIdStorage.run(teamId, fn);
}
import { getWorkspaceTeamId } from "@/chat/slack/workspace-context";
export { runWithWorkspaceTeamId } from "@/chat/slack/workspace-context";

/**
* Return true when a Slack event's author is from an external workspace.
Expand All @@ -23,7 +13,7 @@ export function isExternalSlackUser(
): boolean {
if (!raw) return false;

const workspaceTeamId = workspaceTeamIdStorage.getStore();
const workspaceTeamId = getWorkspaceTeamId();
if (!workspaceTeamId) return false;

const userTeam =
Expand Down
1 change: 1 addition & 0 deletions packages/junior/src/chat/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ const SLACK_ACTION_RULES = [
"- Context-bound Slack tools use runtime-owned targets; do not invent channel, canvas, list, or message IDs.",
"- Use first-class Slack tools for Slack side effects; do not use bash, curl, or provider APIs to bypass Slack tool targeting.",
"- Use channel-post and emoji-reaction tools only when the user explicitly asks for that Slack side effect.",
"- Use Slack schedule tools only when the user explicitly asks to create, list, edit, pause, resume, remove, or run future/recurring Junior work; scheduled task destinations are always the active Slack context, and task creation needs an exact next-run ISO timestamp.",
"- For explicit channel-post or emoji-reaction requests, skip a duplicate thread text reply when the tool result already satisfies the request.",
"- Do not claim an attachment, canvas, channel post, list update, or reaction succeeded unless the tool returned success this turn; when it did, include any link the tool returned.",
"- Do not use reactions as progress indicators.",
Expand Down
15 changes: 14 additions & 1 deletion packages/junior/src/chat/respond.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,11 @@ import {
} from "@/chat/services/turn-checkpoint";
import { createMcpAuthOrchestration } from "@/chat/services/mcp-auth-orchestration";
import { createPluginAuthOrchestration } from "@/chat/services/plugin-auth-orchestration";
import { AuthorizationPauseError } from "@/chat/services/auth-pause";
import {
AuthorizationFlowDisabledError,
AuthorizationPauseError,
type AuthorizationFlowMode,
} from "@/chat/services/auth-pause";

// Re-export types for backward compatibility with existing consumers.
export type { AssistantReply, AgentTurnDiagnostics };
Expand All @@ -104,6 +108,7 @@ export interface ReplyRequestContext {
turnId?: string;
runId?: string;
channelId?: string;
teamId?: string;
messageTs?: string;
threadTs?: string;
requesterId?: string;
Expand All @@ -112,6 +117,7 @@ export interface ReplyRequestContext {
conversationContext?: string;
artifactState?: ThreadArtifactsState;
pendingAuth?: ConversationPendingAuthState;
authorizationFlowMode?: AuthorizationFlowMode;
configuration?: Record<string, unknown>;
/** Durable Pi transcript for this conversation, excluding ephemeral turn context. */
piMessages?: PiMessage[];
Expand Down Expand Up @@ -676,6 +682,7 @@ export async function generateAssistantReply(
getMergedArtifactState: () =>
mergeArtifactsState(context.artifactState ?? {}, artifactStatePatch),
onPendingAuth: context.onAuthPending,
authorizationFlowMode: context.authorizationFlowMode,
},
() => agent?.abort(),
);
Expand All @@ -690,6 +697,7 @@ export async function generateAssistantReply(
channelConfiguration: context.channelConfiguration,
currentPendingAuth: context.pendingAuth,
onPendingAuth: context.onAuthPending,
authorizationFlowMode: context.authorizationFlowMode,
userTokenStore,
},
() => agent?.abort(),
Expand Down Expand Up @@ -777,6 +785,8 @@ export async function generateAssistantReply(
{
channelId: toolChannelId,
channelCapabilities,
requester: context.requester,
teamId: context.correlation?.teamId,
messageTs: context.correlation?.messageTs,
threadTs: context.correlation?.threadTs,
userText: userInput,
Expand Down Expand Up @@ -1192,6 +1202,9 @@ export async function generateAssistantReply(
if (isRetryableTurnError(error)) {
throw error;
}
if (error instanceof AuthorizationFlowDisabledError) {
throw error;
}

logException(
error,
Expand Down
3 changes: 3 additions & 0 deletions packages/junior/src/chat/runtime/reply-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
getAssistantThreadContext,
getChannelId,
getMessageTs,
getTeamId,
getThreadId,
getThreadTs,
getRunId,
Expand Down Expand Up @@ -140,6 +141,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) {
const threadTs = getThreadTs(threadId);
const assistantThreadContext = getAssistantThreadContext(message);
const messageTs = getMessageTs(message);
const teamId = getTeamId(message);
const runId = getRunId(thread, message);
const conversationId = threadId ?? runId;

Expand Down Expand Up @@ -400,6 +402,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) {
turnId,
threadTs,
messageTs,
teamId,
Comment thread
cursor[bot] marked this conversation as resolved.
runId,
channelId,
requesterId: message.author.userId,
Expand Down
17 changes: 17 additions & 0 deletions packages/junior/src/chat/runtime/thread-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Message, Thread } from "chat";
import { botConfig } from "@/chat/config";
import { toOptionalString } from "@/chat/coerce";
import { isDmChannel, normalizeSlackConversationId } from "@/chat/slack/client";
import { getWorkspaceTeamId } from "@/chat/slack/workspace-context";
import {
parseSlackThreadId,
resolveSlackChannelIdFromThreadId,
Expand Down Expand Up @@ -127,3 +128,19 @@ export function getMessageTs(message: Message): string | undefined {
toOptionalString((rawRecord.message as { ts?: unknown } | undefined)?.ts)
);
}

/** Resolve the Slack workspace/team id from the raw inbound message payload. */
export function getTeamId(message: Message): string | undefined {
const raw = (message as unknown as { raw?: unknown }).raw;
if (!raw || typeof raw !== "object") {
return undefined;
}

const rawRecord = raw as Record<string, unknown>;
return (
toOptionalString(rawRecord.team_id) ??
toOptionalString(rawRecord.team) ??
getWorkspaceTeamId() ??
toOptionalString(rawRecord.user_team)
);
}
Loading
Loading