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
5 changes: 2 additions & 3 deletions openspec/changes/actor-identity-end-state/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,8 @@ This matches the current rules on `main`:
- Another Actor's message does not join an active Actor's credentialed work.
The worker puts messages in one Turn only when their author and Source
match (`selectContiguousTurnBatch` in
`task-execution/attempt-selection.ts`). By default, another Actor's
mid-run Slack ask waits for its own Turn (`JUNIOR_CROSS_ACTOR_MID_RUN_MODE`,
`follow_up`).
`task-execution/attempt-selection.ts`). Another Actor's mid-run Slack
ask always waits for its own Turn.
- A system-acted event Turn uses the grants of a Watch creator only through
`timerWatchCredentialSubject` (`chat/events/notification.ts`). It gives a
credential subject only when every event in the batch has a valid subject
Expand Down
6 changes: 0 additions & 6 deletions packages/docs/src/content/docs/reference/config-and-env.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ related:
| `JUNIOR_CONVERSATION_WORK_ENABLED` | No | Operational kill switch for queue processing and heartbeat recovery. Defaults to `true`; set `false` to acknowledge wakes without running or recovering conversation work. |
| `JUNIOR_SECRET` | Yes | Signs internal queue/callback payloads and sandbox egress actor context. |
| `JUNIOR_BOT_NAME` | No | Bot display/config naming. |
| `JUNIOR_CROSS_ACTOR_MID_RUN_MODE` | No | Cross-actor Slack steering policy. Defaults to `follow_up`; see below. |
| `AI_MODEL` | No | Deprecated profile setting. Creates `standard` and remains the fallback for `AI_FAST_MODEL`. Defaults to `openai/gpt-6-luna` with high reasoning. |
| `AI_REASONING_LEVEL` | No | Main-agent reasoning override: `none`, `low`, `medium`, `high`, or `xhigh`. Unset by default. An explicit profile reasoning level takes precedence when routing is enabled. |
| `AI_FAST_MODEL` | No | Faster model for lightweight tasks and routing/classification passes before the main turn begins. Defaults to `openai/gpt-6-luna`. |
Expand Down Expand Up @@ -82,11 +81,6 @@ Redis, each one can take the other's jobs.

Junior applies `JUNIOR_SQL_STATEMENT_TIMEOUT_MS` through PostgreSQL `statement_timeout` for both the Neon and node-postgres drivers. `junior upgrade` does not apply this runtime limit because schema migrations can legitimately take longer.

`JUNIOR_CROSS_ACTOR_MID_RUN_MODE=follow_up` keeps another actor's mid-run ask
for its own turn. Set it to `steer` to preserve collaborative steering across
actors. In `follow_up` mode, a user can start one message with `!!` to steer the
active turn explicitly.

Model profile names are durable conversation bindings. Each later turn resolves
the stored name through current configuration; the exact model ID recorded when
an epoch opens is audit evidence, not a runtime pin. Changing a mapping retargets
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ describe("Slack Turn Steering", () => {
]);
});

test("when other people mention Junior during a turn, each person gets a follow-up turn unless they override with !!", async ({
test("when other people mention Junior during a turn, each person gets a follow-up turn, even with !!", async ({
run,
}) => {
// Only routing is asserted, so the inputs are manufactured and short.
Expand All @@ -72,48 +72,21 @@ describe("Slack Turn Steering", () => {
},
);

// Sam's turn, then Rio, Kai, and Rio again in arrival order. Alex's !!
// mention joins Sam's running turn.
// Sam's turn, then Rio, Kai, Rio again, and Alex in arrival order. The
// running turn uses Sam's credentials, so !! does not let Alex join it.
expect(conversation.turns.map((turn) => turn.status)).toEqual([
"succeeded",
"succeeded",
"succeeded",
"succeeded",
"succeeded",
]);
// Each person whose mention got its own turn joins the conversation.
expect(conversation.participantSlackUserIds).toEqual(
expect.arrayContaining(["U0SAM", "U0RIO", "U0KAI"]),
expect.arrayContaining(["U0SAM", "U0RIO", "U0KAI", "U0ALEX"]),
);
});

test("when cross-actor steering is configured, another person's mention joins the running turn", async ({
agent,
}) => {
const { run } = await agent({
slack: { crossActorMidRunMode: "steer" },
});
const conversation = await run(
slackMention(INCIDENT_REQUEST, { author: person("U0SAM", "Sam") }),
{
onProgress: sendDuringFirstModelRequest([
slackMention("include the rollback owner: Dana", {
author: person("U0RIO", "Rio"),
}),
]),
},
);
await expect(conversation).toSatisfyJudge(
RubricJudge,
rubric({
pass: ["The summary names Dana as the rollback owner."],
}),
);

expect(conversation.turns.map((turn) => turn.status)).toEqual([
"succeeded",
]);
});

test("when a thread message needs no reply, Junior consumes it without a turn", async ({
agent,
}) => {
Expand Down
72 changes: 0 additions & 72 deletions packages/junior-evals/evals/memory/actors.eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
} from "@junior-evals/fixture/inputs";
import { rubric, RubricJudge } from "@junior-evals/fixture/judge";
import { readMemories } from "@junior-evals/fixture/memory";
import { sendDuringFirstModelRequest } from "@junior-evals/fixture/progress";
import { test } from "@junior-evals/fixture/test";

/**
Expand Down Expand Up @@ -129,77 +128,6 @@ describe("Memory with Multiple Actors", () => {
}
});

test("when another User steers a preference into the active turn, do not store it as the Actor's user memory", async ({
run,
}) => {
await run(
slackMention("Can you recap what has been asked in this thread so far?", {
author: ALICE,
}),
{
onProgress: sendDuringFirstModelRequest([
slackMention(
"!! When you write up the recap, I prefer short bullet summaries over prose.",
{ author: BOB },
),
]),
},
);

const memories = await readThreadMemories();
// Multi-actor runs never store any preference from passive extraction, no
// matter whose instruction the citations point at.
expect(memories.filter((memory) => memory.kind === "preference")).toEqual(
[],
);
// Anti-laundering: Bob's stated preference content must not resurface as any
// stored memory of any kind. This path is prompt-defended only, so this
// assertion is its real coverage.
for (const memory of memories) {
expect(memory.content).not.toMatch(/bullet/i);
}
});

// Issue #776: the case the citation router alone cannot cover. Alice's own
// durable first-person preference cites only her own run-Actor instruction.
// A single-Actor run can store this as user memory, but Bob's steering makes
// this a multi-Actor run, so no preference may be stored.
test("when the actor states their own durable preference in a multi-actor turn, store no preference memory at all", async ({
run,
}) => {
await run(
slackMention(
"I prefer recaps as numbered lists, not paragraphs. Can you recap the asks in this thread so far?",
{ author: ALICE },
),
{
onProgress: sendDuringFirstModelRequest([
slackMention(
"!! Open question: should we pause the launch? Please list it when you get a chance.",
{ author: BOB },
),
]),
},
);

const memories = await readThreadMemories();
// The multi-actor gate: no active preference may exist, even though the
// preference is the run actor's own and its citations would pass the
// single-actor router.
expect(memories.filter((memory) => memory.kind === "preference")).toEqual(
[],
);
// Preferences are the only route into a user subject for passive
// extraction, so the user-subject memories must stay empty too.
expect(await readUserMemories()).toEqual([]);
// Anti-laundering: the preference must not resurface as knowledge or
// procedure. This path is prompt-defended only, so this assertion is its
// real coverage.
for (const memory of memories) {
expect(memory.content).not.toMatch(/numbered/i);
}
});

// TDD target (issue #773): red until the completed-run projection carries
// non-actor public messages as conversation-scope evidence. Today a
// passive participant's knowledge never reaches passive extraction: it is
Expand Down
11 changes: 0 additions & 11 deletions packages/junior/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,10 @@ import {
resetRuntimeConfig,
restoreRuntimeConfig as restoreChatRuntimeConfig,
setBotModelConfig,
setCrossActorMidRunMode,
setProfiles,
setRuntimeLimits,
snapshotRuntimeConfig,
type BotModelConfig,
type CrossActorMidRunMode,
type RuntimeLimits,
setSlackReactionConfig,
} from "@/chat/config";
Expand Down Expand Up @@ -200,12 +198,6 @@ export interface JuniorAppOptions extends BotModelConfig {
processingReactionEmoji?: string;
/** Slack emoji shown after a turn completes. Defaults to `white_check_mark`. */
completedReactionEmoji?: string;
/**
* What a mention from another person does while a turn runs: `follow_up`
* waits for its own turn, `steer` joins the running turn. Defaults to
* `JUNIOR_CROSS_ACTOR_MID_RUN_MODE` or `follow_up`.
*/
crossActorMidRunMode?: CrossActorMidRunMode;
/**
* Slack user IDs of bots that may start a Turn from a mention or DM. Bots
* have no profile email, so `allowedDomains` blocks them otherwise.
Expand Down Expand Up @@ -549,9 +541,6 @@ export async function createApp(
setBotModelConfig(options ?? {});
setRuntimeLimits(options?.limits);
setSlackReactionConfig(options?.slack ?? {});
if (options?.slack?.crossActorMidRunMode) {
setCrossActorMidRunMode(options.slack.crossActorMidRunMode);
}
// One domain list for Slack authors and dashboard sign-ins.
assertNoLegacyAllowedDomains(Object.keys(dashboard ?? {}));
allowedDomains = resolveAllowedDomains(options?.allowedDomains);
Expand Down
21 changes: 0 additions & 21 deletions packages/junior/src/chat/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@ export interface BotModelConfig {

export interface BotConfig {
contextWindowTokens: number;
crossActorMidRunMode: CrossActorMidRunMode;
decisionModelId: string;
defaultProfile: ModelProfile;
embeddingModelId: string;
Expand All @@ -124,8 +123,6 @@ export interface BotConfig {
webSearchModelId: string;
}

export type CrossActorMidRunMode = "follow_up" | "steer";

export type SqlDriver = "neon" | "postgres";

export interface ChatConfig {
Expand Down Expand Up @@ -247,16 +244,6 @@ function parseConversationWorkEnabled(rawValue: string | undefined): boolean {
throw new Error("JUNIOR_CONVERSATION_WORK_ENABLED must be true or false");
}

function parseCrossActorMidRunMode(
rawValue: string | undefined,
): CrossActorMidRunMode {
const value = toOptionalTrimmed(rawValue) ?? "follow_up";
if (value === "follow_up" || value === "steer") {
return value;
}
throw new Error("JUNIOR_CROSS_ACTOR_MID_RUN_MODE must be follow_up or steer");
}

const DEFAULT_MODEL_ID = resolveGatewayModel(
DEFAULT_MODEL_PROFILES.standard.modelId,
).id;
Expand Down Expand Up @@ -495,9 +482,6 @@ function readBotConfig(
return {
userName: toOptionalTrimmed(env.JUNIOR_BOT_NAME) ?? "junior",
defaultProfile: "standard",
crossActorMidRunMode: parseCrossActorMidRunMode(
env.JUNIOR_CROSS_ACTOR_MID_RUN_MODE,
),
profiles: parseProfiles(env.AI_MODEL_PROFILES, modelId, handoffModelId),
contextWindowTokens:
parseOptionalPositiveInteger(
Expand Down Expand Up @@ -798,11 +782,6 @@ export function setRuntimeLimits(limits: RuntimeLimits | undefined): void {
}
}

/** Apply the Slack cross-actor steering mode from createApp(). */
export function setCrossActorMidRunMode(mode: CrossActorMidRunMode): void {
chatConfig.bot.crossActorMidRunMode = parseCrossActorMidRunMode(mode);
}

/** Return the chat configuration (parsed once at startup). */
export function getChatConfig(): ChatConfig {
return chatConfig;
Expand Down
21 changes: 7 additions & 14 deletions packages/junior/src/chat/task-execution/slack-work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,7 @@ import {
requireSlackDestination,
} from "@/chat/destination";
import { stripLeadingSteeringOverride } from "@/chat/slack/message-control";
import {
botConfig,
getChatConfig,
type CrossActorMidRunMode,
} from "@/chat/config";
import { getChatConfig } from "@/chat/config";

const slackConversationRouteSchema = z.enum(["mention", "subscribed"]);
export type SlackConversationRoute = z.output<
Expand Down Expand Up @@ -455,7 +451,6 @@ interface SlackInboxTurnRuntime {
}

export interface CreateSlackConversationWorkerOptions {
crossActorMidRunMode?: CrossActorMidRunMode;
getSlackAdapter: () => SlackAdapter;
lookupSlackUser?: (
teamId: string,
Expand Down Expand Up @@ -655,8 +650,6 @@ function getPendingRecords(
export function createSlackConversationWorker(
options: CreateSlackConversationWorkerOptions,
): (context: ConversationWorkerContext) => Promise<ConversationWorkerResult> {
const crossActorMidRunMode =
options.crossActorMidRunMode ?? botConfig.crossActorMidRunMode;
return async (context) => {
const adapter = options.getSlackAdapter();
const actorLookup = options.lookupSlackUser ?? lookupSlackUser;
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved
Expand Down Expand Up @@ -766,9 +759,11 @@ export function createSlackConversationWorker(
);
}
};
// Only explicit Slack interruptions enter the active turn. Deferred
// work and steered messages from other sources, such as the
// dashboard, stay pending for their own turn.
// Only explicit Slack interruptions from the active author enter the
// active turn. The turn runs with that author's credentials, so another
// person's message always waits for its own turn. Deferred work and
// steered messages from other sources, such as the dashboard, stay
// pending for their own turn.
const drainSteeringMessages = async (
accept: (
messages: SteeringCandidateMessage[],
Expand All @@ -786,9 +781,7 @@ export function createSlackConversationWorker(
}))
.filter(
(candidate) =>
crossActorMidRunMode === "steer" ||
requireSlackAuthorId(candidate.message) === activeAuthorId ||
hasSteeringOverride(candidate.message.text),
requireSlackAuthorId(candidate.message) === activeAuthorId,
);
return candidates.length > 0 ? await accept(candidates) : [];
});
Expand Down
1 change: 0 additions & 1 deletion packages/junior/src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,6 @@ export async function runInit(
SLACK_SIGNING_SECRET=
JUNIOR_SECRET=
JUNIOR_BOT_NAME=
JUNIOR_CROSS_ACTOR_MID_RUN_MODE=
AI_MODEL=
AI_FAST_MODEL=
AI_GUARDIAN_MODEL=
Expand Down
19 changes: 0 additions & 19 deletions packages/junior/tests/component/config/chat-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,25 +569,6 @@ describe("chat config", () => {
);
});

it("defaults cross-actor mid-run messages to follow-up", async () => {
delete process.env.JUNIOR_CROSS_ACTOR_MID_RUN_MODE;
const { botConfig } = await loadConfig();
expect(botConfig.crossActorMidRunMode).toBe("follow_up");
});

it("supports collaborative cross-actor steering", async () => {
process.env.JUNIOR_CROSS_ACTOR_MID_RUN_MODE = "steer";
const { botConfig } = await loadConfig();
expect(botConfig.crossActorMidRunMode).toBe("steer");
});

it("rejects unsupported cross-actor mid-run modes", async () => {
process.env.JUNIOR_CROSS_ACTOR_MID_RUN_MODE = "router";
await expect(loadConfig()).rejects.toThrow(
"JUNIOR_CROSS_ACTOR_MID_RUN_MODE must be follow_up or steer",
);
});

it("sets max slices per turn from core config", async () => {
const { botConfig } = await loadConfig();
expect(botConfig.maxSlicesPerTurn).toBe(100);
Expand Down
Loading
Loading