Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
fix(slack): reject user-token mention identity
  • Loading branch information
steipete authored Jul 5, 2026
commit 03befdabb07dce2c52f283c589474dfa25ec366e
Original file line number Diff line number Diff line change
Expand Up @@ -7778,21 +7778,25 @@ public struct ChatAbortParams: Codable, Sendable {
public let sessionkey: String
public let agentid: String?
public let runid: String?
public let preservesideruns: Bool?

public init(
sessionkey: String,
agentid: String? = nil,
runid: String?)
runid: String?,
preservesideruns: Bool? = nil)
{
self.sessionkey = sessionkey
self.agentid = agentid
self.runid = runid
self.preservesideruns = preservesideruns
}

private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case agentid = "agentId"
case runid = "runId"
case preservesideruns = "preserveSideRuns"
}
}

Expand Down
22 changes: 22 additions & 0 deletions docs/concepts/queue.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,28 @@ keys.
- Applies to auto-reply agent runs across all inbound channels that use the gateway reply pipeline (WhatsApp web, Telegram, Slack, Discord, Signal, iMessage, webchat, etc.).
- Default lane (`main`) is process-wide for inbound + main heartbeats; set `agents.defaults.maxConcurrent` to allow multiple sessions in parallel.
- Additional lanes may exist (e.g. `cron`, `cron-nested`, `nested`, `subagent`) so background jobs can run in parallel without blocking inbound replies. Isolated cron agent turns hold a `cron` slot while their inner agent execution uses `cron-nested`; both use `cron.maxConcurrentRuns`. Shared non-cron `nested` flows keep their own lane behavior. These detached runs are tracked as [background tasks](/automation/tasks).

## Queued-turn cancellation

When Gateway admits a prompt into the followup/collect queue (for example a TUI
or webchat `chat.send` while another turn is active), it keeps a **Gateway-owned
cancel identity** for that client `runId` until the queued content runs or is
dropped. The identity follows content folded into an overflow summary.

- `chat.abort` with a specific `runId` cancels that turn while it is still queued,
if the requester is authorized (same ownership rules as active runs).
- `chat.abort` for a session without `runId` cancels **authorized queued turns
first**, then aborts authorized active runs. That order prevents queue drain
from promoting work into a half-stopped session.
- Clearing the entire session queue without per-requester checks is not the stop
path for multi-owner sessions.
- Queued waits are not projected as active agent runs for `sessions.list` and do
not own active-run timeout semantics; only the active phase does.

Clients (including the TUI) forward mid-run prompts and let Gateway apply the
queue mode. Esc/`/stop` uses a session-scoped abort so lost local handles cannot
leave a still-queued prompt running.

- Per-session lanes guarantee that only one agent run touches a given session at a time.
- No external dependencies or background worker threads; pure TypeScript + promises.

Expand Down
1 change: 1 addition & 0 deletions docs/docs_map.md
Original file line number Diff line number Diff line change
Expand Up @@ -2757,6 +2757,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Precedence
- H2: Per-session overrides
- H2: Scope and guarantees
- H2: Queued-turn cancellation
- H2: Troubleshooting
- H2: Related

Expand Down
1 change: 1 addition & 0 deletions packages/gateway-protocol/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ describe("lazy protocol validators", () => {
sessionKey: "global",
agentId: "work",
runId: "run-global-work",
preserveSideRuns: true,
}),
).toBe(true);
expect(
Expand Down
1 change: 1 addition & 0 deletions packages/gateway-protocol/src/schema/logs-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export const ChatAbortParamsSchema = Type.Object(
sessionKey: NonEmptyString,
agentId: Type.Optional(NonEmptyString),
runId: Type.Optional(NonEmptyString),
preserveSideRuns: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
Expand Down
4 changes: 2 additions & 2 deletions scripts/protocol-gen-swift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[]
["SessionsUsageParams", ["agentId", "agentScope"]],
["ChatHistoryParams", ["agentId", "offset"]],
["ChatSendParams", ["agentId"]],
["ChatAbortParams", ["agentId"]],
["ChatAbortParams", ["agentId", "preserveSideRuns"]],
["ChatInjectParams", ["agentId"]],
["ChatDeltaEvent", ["agentId"]],
["ChatFinalEvent", ["agentId"]],
Expand All @@ -73,7 +73,7 @@ const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[]
["ArtifactsListParams", ["agentId"]],
["ArtifactsGetParams", ["agentId"]],
["ArtifactsDownloadParams", ["agentId"]],
["ChatAbortParams", ["agentId"]],
["ChatAbortParams", ["agentId", "preserveSideRuns"]],
["ChatAbortedEvent", ["agentId"]],
["ChatDeltaEvent", ["agentId"]],
["ChatErrorEvent", ["agentId"]],
Expand Down
129 changes: 129 additions & 0 deletions src/agents/embedded-agent-runner/run/preemptive-compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,4 +455,133 @@ describe("preemptive-compaction", () => {
expect(result.route).toBe("truncate_tool_results_only");
expect(result.shouldCompact).toBe(false);
});

it("estimates CJK tool results at roughly one token per character", () => {
const cjkText = "中".repeat(85_000);
const toolResultTokens = estimateLlmBoundaryTokenPressure({
messages: [makeToolResultMessage(cjkText)],
systemPrompt: "sys",
prompt: "continue",
});
const assistantTokens = estimateLlmBoundaryTokenPressure({
messages: [makeAssistantHistory(cjkText)],
systemPrompt: "sys",
prompt: "continue",
});
const result = shouldPreemptivelyCompactBeforePrompt({
messages: [makeToolResultMessage(cjkText)],
systemPrompt: "sys",
prompt: "continue",
contextTokenBudget: 128_000,
reserveTokens: 20_000,
});

expect(toolResultTokens).toBeGreaterThanOrEqual(assistantTokens);
expect(toolResultTokens - assistantTokens).toBeLessThanOrEqual(5);
expect(result.estimatedPromptTokens).toBe(toolResultTokens);
expect(result.promptBudgetBeforeReserve).toBeGreaterThan(result.estimatedPromptTokens);
expect(result.route).toBe("fits");
expect(result.shouldCompact).toBe(false);
expect(result.overflowTokens).toBe(0);
});

it("avoids false overflow when CJK is less than half of a tool result", () => {
const mixedContent = "中".repeat(40_000) + "a".repeat(60_000);
const result = shouldPreemptivelyCompactBeforePrompt({
messages: [makeToolResultMessage(mixedContent)],
systemPrompt: "sys",
prompt: "continue",
contextTokenBudget: 100_000,
reserveTokens: 20_000,
});

expect(result.estimatedPromptTokens).toBeLessThan(result.promptBudgetBeforeReserve);
expect(result.route).toBe("fits");
expect(result.shouldCompact).toBe(false);
});

it("keeps mixed-script estimates monotonic across the former CJK cutoff", () => {
const estimate = (cjkChars: number) =>
estimateLlmBoundaryTokenPressure({
messages: [makeToolResultMessage("中".repeat(cjkChars) + "a".repeat(10_000 - cjkChars))],
systemPrompt: "sys",
prompt: "continue",
});

const belowCutoff = estimate(4_999);
const atCutoff = estimate(5_000);
const aboveCutoff = estimate(5_001);

expect(atCutoff).toBeGreaterThanOrEqual(belowCutoff);
expect(aboveCutoff).toBeGreaterThanOrEqual(atCutoff);
expect(aboveCutoff - belowCutoff).toBeLessThanOrEqual(2);
});

it("keeps the conservative ratio for non-CJK tool results", () => {
const latinText = "alpha beta gamma delta epsilon ".repeat(1000);
const toolResultTokens = estimateLlmBoundaryTokenPressure({
messages: [makeToolResultMessage(latinText)],
systemPrompt: "sys",
prompt: "continue",
});
const assistantTokens = estimateLlmBoundaryTokenPressure({
messages: [makeAssistantHistory(latinText)],
systemPrompt: "sys",
prompt: "continue",
});

expect(toolResultTokens).toBeGreaterThan(assistantTokens * 1.5);
expect(toolResultTokens).toBeLessThan(assistantTokens * 2.5);
});

it("applies the CJK-aware ratio to JSON tool-result payloads", () => {
const cjkPayload = {
summary: "中文内容".repeat(5_000),
note: "更多中文文本".repeat(2_000),
};
const messages = [makeJsonToolResultMessage(cjkPayload)];

const estimatedPromptTokens = estimateLlmBoundaryTokenPressure({
messages,
systemPrompt: "sys",
prompt: "continue",
});

expect(estimatedPromptTokens).toBeLessThan(90_000);

const result = shouldPreemptivelyCompactBeforePrompt({
messages,
systemPrompt: "sys",
prompt: "continue",
contextTokenBudget: 128_000,
reserveTokens: 20_000,
});

expect(result.route).toBe("fits");
expect(result.shouldCompact).toBe(false);
expect(result.overflowTokens).toBe(0);
});

it("does not throw when tool-result content cannot be serialized", () => {
const circular: Record<string, unknown> = { self: undefined };
circular.self = circular;
const message = {
role: "toolResult",
toolCallId: "call_circular",
toolName: "bad_tool",
content: circular,
isError: false,
timestamp: timestamp++,
} as unknown as AgentMessage;

const result = shouldPreemptivelyCompactBeforePrompt({
messages: [message],
systemPrompt: "sys",
prompt: "continue",
contextTokenBudget: 128_000,
reserveTokens: 20_000,
});

expect(Number.isFinite(result.estimatedPromptTokens)).toBe(true);
});
});
44 changes: 38 additions & 6 deletions src/agents/embedded-agent-runner/run/preemptive-compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,18 +112,50 @@ function estimateContentBlockTokenPressure(
return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateJsonPayloadTokenPressure(block, charsPerToken);
}

function estimateToolResultStringTokenPressure(text: string): number {
const conservativeToolResultEstimate = Math.ceil(text.length / TOOL_RESULT_CHARS_PER_TOKEN);
const cjkAwareEstimate = estimateStringTokenPressure(text);
return Math.max(conservativeToolResultEstimate, cjkAwareEstimate);
}

function estimateToolResultJsonTokenPressure(value: unknown): number {
try {
const serialized = JSON.stringify(value);
return typeof serialized === "string" ? estimateToolResultStringTokenPressure(serialized) : 1;
} catch {
return 256;
}
}

function estimateToolResultBlockTokenPressure(block: unknown): number {
if (typeof block === "string") {
return estimateToolResultStringTokenPressure(block);
}
if (!isRecord(block)) {
return estimateToolResultJsonTokenPressure(block);
}

if (block.type === "text" && typeof block.text === "string") {
return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateToolResultStringTokenPressure(block.text);
}
if (block.type === "thinking" && typeof block.thinking === "string") {
return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateToolResultStringTokenPressure(block.thinking);
}
if (block.type === "image") {
return IMAGE_BLOCK_TOKENS;
}
return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateToolResultJsonTokenPressure(block);
}

function estimateToolResultContentTokenPressure(content: unknown): number {
if (typeof content === "string") {
return estimateStringTokenPressure(content, TOOL_RESULT_CHARS_PER_TOKEN);
return estimateToolResultStringTokenPressure(content);
}
if (Array.isArray(content)) {
return content.reduce(
(sum, block) => sum + estimateContentBlockTokenPressure(block, TOOL_RESULT_CHARS_PER_TOKEN),
0,
);
return content.reduce((sum, block) => sum + estimateToolResultBlockTokenPressure(block), 0);
}
if (content !== undefined) {
return estimateJsonPayloadTokenPressure(content, TOOL_RESULT_CHARS_PER_TOKEN);
return estimateToolResultJsonTokenPressure(content);
}
return 0;
}
Expand Down
9 changes: 8 additions & 1 deletion src/auto-reply/get-reply-options.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,14 @@ export type QueuedReplyDeliveryCorrelation = {

/** Lifecycle hooks for queued follow-up replies. */
export type QueuedReplyLifecycle = {
onEnqueued?: () => void;
/** Stable cancellation owner used to keep collect-mode batches authorization-safe. */
ownerKey?: string;
/** Return false when the external owner rejects this queue identity. */
onEnqueued?: () => boolean | void;
/** Retires this source's cancellation ownership while retaining its live identity. */
onCancellationRetired?: () => void;
/** Called after the queued turn owns the reply lane, before model/tool execution. */
onAdmitted?: () => void;
onComplete?: () => void;
};

Expand Down
42 changes: 42 additions & 0 deletions src/auto-reply/reply/dispatch-from-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2198,6 +2198,48 @@ describe("dispatchReplyFromConfig", () => {
}
});

it("lets Gateway-owned turns reach queue resolution while a reply operation is active", async () => {
setNoAbort();
const sessionKey = "agent:main:main";
const activeOperation = createReplyOperation({
sessionKey,
sessionId: "active-session",
resetTriggered: false,
});
activeOperation.setPhase("running");
const dispatcher = createDispatcher();
const replyResolver = vi.fn(async () => undefined);

try {
const result = await dispatchReplyFromConfig({
ctx: buildTestCtx({
Provider: "webchat",
Surface: "webchat",
SessionKey: sessionKey,
BodyForAgent: "queue this turn",
}),
cfg: emptyConfig,
dispatcher,
replyOptions: {
queuedFollowupLifecycle: {
onEnqueued: vi.fn(),
onComplete: vi.fn(),
},
},
replyResolver,
});

expect(result).toMatchObject({
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
});
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(replyRunRegistry.get(sessionKey)).toBe(activeOperation);
} finally {
activeOperation.complete();
}
});

it("clears stale active reply operations for terminal sessions and retries admission", async () => {
setNoAbort();
const sessionKey = "agent:main:telegram:group:-1003774691294";
Expand Down
12 changes: 11 additions & 1 deletion src/auto-reply/reply/dispatch-from-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,11 @@ import {
toPluginMessageContext,
toPluginMessageReceivedEvent,
} from "../../hooks/message-hook-mappers.js";
import { isAbortError } from "../../infra/abort-signal.js";
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
import { measureDiagnosticsTimelineSpan } from "../../infra/diagnostics-timeline.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
import { isAbortError } from "../../infra/abort-signal.js";
import type { StuckSessionRecoveryOutcome } from "../../logging/diagnostic-session-recovery.js";
import {
logMessageDispatchCompleted,
Expand Down Expand Up @@ -1452,6 +1452,16 @@ export async function dispatchReplyFromConfig(
crypto.randomUUID();
const replyTurnKind = resolveReplyTurnKind(params.replyOptions);
const allowActivePreDispatch = phase === "pre_dispatch" && replyTurnKind === "visible";
const allowGatewayQueueResolution =
phase === "dispatch" &&
replyTurnKind === "visible" &&
params.replyOptions?.queuedFollowupLifecycle !== undefined &&
replyRunRegistry.get(dispatchOperationSessionKey) !== undefined;
if (allowGatewayQueueResolution) {
// Gateway turns need to reach getReplyFromConfig while the owner is active;
// that layer applies the session's steer/followup/collect/drop policy.
return { status: "ready" };
}
const allowSlackRoutedThreadBypass =
phase === "dispatch" &&
shouldLetSlackRoutedThreadBypassBusyReplyOperation({
Expand Down
Loading
Loading