sortie-dogs 0.2.14 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -146,7 +146,7 @@ Optional settings in `.opencode/sortie-dogs.json`:
146
146
  "handoffPaths": ["handoff.json"],
147
147
  "readOnlyTools": ["my_mcp_search"],
148
148
  "dedicatedWorkerModel": { "model": "provider/model", "variant": "deep" },
149
- "continuation": { "enabled": true, "maxAutoContinues": 3 },
149
+ "continuation": { "enabled": true, "maxAutoContinues": 2 },
150
150
  "reflection": {
151
151
  "enabled": false,
152
152
  "layers": { "run": true, "project": true, "global": false }
@@ -180,9 +180,9 @@ global file for durable global settings.
180
180
  and resumes the same root session on the next independent unit. Only a root
181
181
  `dog-coordinator` session is ever resumed: a child session is never promoted and
182
182
  another coordinator is never adopted. Set `enabled` to `false` to keep every
183
- batch manual, raise or lower `maxAutoContinues` (default `3`, maximum `10`) to
184
- change the ceiling, and set `summarizeModel` to pin the compaction model when
185
- the host default is unsuitable. Normal OpenCode auto-compaction keeps the
183
+ batch manual, raise or lower `maxAutoContinues` (default `2`, maximum `10`) to
184
+ change the ceiling, and set `summarizeModel` to override the latest coordinator
185
+ model used for compaction. Normal OpenCode auto-compaction keeps the
186
186
  host's auto-continue behavior; Sortie suppresses it only while its own
187
187
  explicitly queued rollover owns the resume.
188
188
  - `reflection` is an opt-in process-prevention companion for an activated root
@@ -44,7 +44,7 @@ export interface ContinuationConfiguration {
44
44
  readonly agent: string;
45
45
  readonly capability: string;
46
46
  readonly maxAutoContinues: number;
47
- /** Absent means the host picks the compaction model; this package never pins one. */
47
+ /** Absent reuses the latest coordinator model observed for this session. */
48
48
  readonly summarizeModel?: ModelTarget;
49
49
  }
50
50
  export interface ConsultationPolicyInput {
@@ -22,7 +22,7 @@ export declare const ROLLOVER_MARKER = "<!-- SORTIE_COMPACT -->";
22
22
  export declare const AUTO_CONTINUE_PREFIX = "SORTIE_AUTO_CONTINUE";
23
23
  /** First line the rollover summary must emit, mirroring the batch target of three attempts. */
24
24
  export declare const ROLLOVER_TOKEN = "SORTIE_ROLLOVER_COMPACTED";
25
- export declare const DEFAULT_MAX_AUTO_CONTINUES = 3;
25
+ export declare const DEFAULT_MAX_AUTO_CONTINUES = 2;
26
26
  /**
27
27
  * A coordinator that exhausted its step budget reports remaining work instead of continuing. That
28
28
  * report is a continuation request in every respect except the marker, so it is treated as one.
@@ -34,12 +34,12 @@ declare const DEFAULT_TIMINGS: {
34
34
  readonly cooldownMilliseconds: 60000;
35
35
  /** Let the new summary's token state settle before a fresh turn can trigger overflow compaction. */
36
36
  readonly settleMilliseconds: 1500;
37
- /** A rollover that cannot start yet is retried on a short backoff rather than dropped. */
38
- readonly scheduleMilliseconds: 1500;
37
+ /** Session idle starts rollover normally; this delayed path only recovers a missing idle event. */
38
+ readonly scheduleMilliseconds: 30000;
39
39
  readonly scheduleAttempts: 2;
40
40
  };
41
41
  export type ContinuationTimings = typeof DEFAULT_TIMINGS;
42
- export type ContinuationRejection = "identity-unavailable" | "child-session" | "agent-mismatch" | "capability-unavailable" | "continuation-disabled" | "limit-reached" | "pending-autocontinue";
42
+ export type ContinuationRejection = "identity-unavailable" | "child-session" | "agent-mismatch" | "capability-unavailable" | "continuation-disabled" | "limit-reached" | "pending-autocontinue" | "summarize-model-unavailable";
43
43
  export interface ContinuationIdentity {
44
44
  readonly agent?: string | undefined;
45
45
  readonly parentID?: string | undefined;
@@ -82,7 +82,7 @@ export interface ContinuationClient {
82
82
  query?: {
83
83
  directory?: string;
84
84
  };
85
- body?: {
85
+ body: {
86
86
  providerID: string;
87
87
  modelID: string;
88
88
  };
@@ -125,7 +125,7 @@ export type ContinuationPolicySource = ContinuationPolicy | (() => ContinuationP
125
125
  * without an agent field, or answers for a different directory, instead of failing silently.
126
126
  */
127
127
  export type LocalIdentitySource = (sessionID: string) => ContinuationIdentity | undefined;
128
- export type RolloverAbort = "identity-unavailable" | "child-session" | "summarize-unavailable" | "retries-exhausted" | "terminal-identity-rejected";
128
+ export type RolloverAbort = "identity-unavailable" | "child-session" | "summarize-unavailable" | "summarize-model-unavailable" | "retries-exhausted" | "terminal-identity-rejected";
129
129
  export interface ContinuationToolContext {
130
130
  readonly sessionID: string;
131
131
  readonly agent?: string | undefined;
@@ -154,6 +154,11 @@ export interface ContinuationHooks {
154
154
  }, output: {
155
155
  enabled: boolean;
156
156
  }): Promise<void>;
157
+ observeModel(sessionID: string, model: {
158
+ providerID: string;
159
+ modelID: string;
160
+ }): void;
161
+ blocksTool(sessionID: string): boolean;
157
162
  sessionIdle(sessionID: string): Promise<void>;
158
163
  forgetSession(sessionID: string): void;
159
164
  }
@@ -22,7 +22,7 @@ export const ROLLOVER_MARKER = "<!-- SORTIE_COMPACT -->";
22
22
  export const AUTO_CONTINUE_PREFIX = "SORTIE_AUTO_CONTINUE";
23
23
  /** First line the rollover summary must emit, mirroring the batch target of three attempts. */
24
24
  export const ROLLOVER_TOKEN = "SORTIE_ROLLOVER_COMPACTED";
25
- export const DEFAULT_MAX_AUTO_CONTINUES = 3;
25
+ export const DEFAULT_MAX_AUTO_CONTINUES = 2;
26
26
  /**
27
27
  * A coordinator that exhausted its step budget reports remaining work instead of continuing. That
28
28
  * report is a continuation request in every respect except the marker, so it is treated as one.
@@ -35,8 +35,8 @@ const DEFAULT_TIMINGS = {
35
35
  cooldownMilliseconds: 60_000,
36
36
  /** Let the new summary's token state settle before a fresh turn can trigger overflow compaction. */
37
37
  settleMilliseconds: 1_500,
38
- /** A rollover that cannot start yet is retried on a short backoff rather than dropped. */
39
- scheduleMilliseconds: 1_500,
38
+ /** Session idle starts rollover normally; this delayed path only recovers a missing idle event. */
39
+ scheduleMilliseconds: 30_000,
40
40
  scheduleAttempts: 2,
41
41
  };
42
42
  function nonEmpty(value) {
@@ -184,6 +184,7 @@ export function createContinuationHooks(client, directory, policySource, timings
184
184
  attempts: 0,
185
185
  pendingRollover: false,
186
186
  active: false,
187
+ compactedRollover: false,
187
188
  promptPending: false,
188
189
  directUsed: false,
189
190
  touched: Date.now(),
@@ -212,11 +213,30 @@ export function createContinuationHooks(client, directory, policySource, timings
212
213
  return undefined;
213
214
  }
214
215
  }
215
- function summarizeBody() {
216
+ function summarizeBody(state) {
216
217
  const summarizeModel = policy().summarizeModel;
217
- if (summarizeModel === undefined)
218
- return undefined;
219
- return openCodeModel(summarizeModel.model);
218
+ return summarizeModel === undefined ? state.model : openCodeModel(summarizeModel.model);
219
+ }
220
+ function summarizeCallSucceeded(response) {
221
+ if (response === true)
222
+ return true;
223
+ if (response === null || typeof response !== "object" || !("data" in response))
224
+ return false;
225
+ return response.data === true;
226
+ }
227
+ function promptCallSucceeded(response) {
228
+ if (response === undefined || response === true)
229
+ return true;
230
+ if (response === null || typeof response !== "object")
231
+ return false;
232
+ const result = response;
233
+ if (result.error !== undefined)
234
+ return false;
235
+ if (result.response?.ok === false)
236
+ return false;
237
+ if (typeof result.response?.status === "number" && result.response.status >= 400)
238
+ return false;
239
+ return true;
220
240
  }
221
241
  async function runRollover(sessionID) {
222
242
  const state = sessions.get(sessionID);
@@ -227,52 +247,65 @@ export function createContinuationHooks(client, directory, policySource, timings
227
247
  warnRollover(sessionID, "summarize-unavailable");
228
248
  return false;
229
249
  }
230
- const cooldownRemaining = state.lastRollover === undefined
231
- ? 0
232
- : timings.cooldownMilliseconds - (Date.now() - state.lastRollover);
233
- if (cooldownRemaining > 0) {
234
- if (state.cooldownTimer === undefined) {
235
- state.cooldownTimer = unrefTimer(setTimeout(() => {
236
- const current = sessions.get(sessionID);
237
- if (current !== undefined)
238
- current.cooldownTimer = undefined;
239
- void runRollover(sessionID);
240
- }, cooldownRemaining));
241
- }
242
- return false;
243
- }
244
- // A child session must never compact or resume its parent's batch.
245
- const identity = await readIdentity(sessionID);
246
- if (identity === undefined || !nonEmpty(identity.agent)) {
247
- warnRollover(sessionID, "identity-unavailable");
248
- return false;
249
- }
250
- if (nonEmpty(identity.parentID)) {
251
- warnRollover(sessionID, "child-session");
252
- return false;
253
- }
254
- const continueReport = state.continueReport;
250
+ // Acquire the session lock before the first await so idle and fallback timers cannot overlap.
255
251
  state.active = true;
256
- state.promptPending = true;
257
- let summarized = false;
252
+ let compactionStarted = false;
253
+ let compactionSucceeded = false;
258
254
  try {
259
- const body = summarizeBody();
260
- await summarize.call(client.session, {
261
- path: { id: sessionID },
262
- query: { directory },
263
- ...(body === undefined ? {} : { body }),
264
- });
265
- summarized = true;
266
- state.lastRollover = Date.now();
267
- state.pendingRollover = false;
268
- state.continueReport = undefined;
269
- if (continueReport === undefined)
255
+ const cooldownRemaining = state.lastRollover === undefined || state.compactedRollover
256
+ ? 0
257
+ : timings.cooldownMilliseconds - (Date.now() - state.lastRollover);
258
+ if (cooldownRemaining > 0) {
259
+ if (state.cooldownTimer === undefined) {
260
+ state.cooldownTimer = unrefTimer(setTimeout(() => {
261
+ const current = sessions.get(sessionID);
262
+ if (current !== undefined)
263
+ current.cooldownTimer = undefined;
264
+ void runRollover(sessionID);
265
+ }, cooldownRemaining));
266
+ }
267
+ return false;
268
+ }
269
+ // A child session must never compact or resume its parent's batch.
270
+ const identity = await readIdentity(sessionID);
271
+ if (identity === undefined || !nonEmpty(identity.agent)) {
272
+ warnRollover(sessionID, "identity-unavailable");
273
+ return false;
274
+ }
275
+ if (nonEmpty(identity.parentID)) {
276
+ warnRollover(sessionID, "child-session");
277
+ return false;
278
+ }
279
+ if (!state.compactedRollover) {
280
+ const body = summarizeBody(state);
281
+ if (body === undefined) {
282
+ warnRollover(sessionID, "summarize-model-unavailable");
283
+ return false;
284
+ }
285
+ state.promptPending = true;
286
+ compactionStarted = true;
287
+ const summarizedResponse = await summarize.call(client.session, {
288
+ path: { id: sessionID },
289
+ query: { directory },
290
+ body,
291
+ });
292
+ if (!summarizeCallSucceeded(summarizedResponse))
293
+ throw new Error("summarize request rejected");
294
+ state.compactedRollover = true;
295
+ compactionSucceeded = true;
296
+ state.lastRollover = Date.now();
297
+ }
298
+ const continueReport = state.continueReport;
299
+ if (continueReport === undefined) {
300
+ state.pendingRollover = false;
301
+ state.compactedRollover = false;
270
302
  return true;
303
+ }
271
304
  const resume = client?.session?.promptAsync;
272
305
  if (resume === undefined)
273
- return true;
274
- await new Promise((settle) => unrefTimer(setTimeout(settle, timings.settleMilliseconds)));
275
- await resume.call(client.session, {
306
+ throw new Error("resume capability unavailable");
307
+ await new Promise((settle) => setTimeout(settle, timings.settleMilliseconds));
308
+ const resumed = await resume.call(client.session, {
276
309
  path: { id: sessionID },
277
310
  query: { directory },
278
311
  body: {
@@ -285,6 +318,11 @@ export function createContinuationHooks(client, directory, policySource, timings
285
318
  }],
286
319
  },
287
320
  });
321
+ if (!promptCallSucceeded(resumed))
322
+ throw new Error("resume request rejected");
323
+ state.pendingRollover = false;
324
+ state.compactedRollover = false;
325
+ state.continueReport = undefined;
288
326
  return true;
289
327
  }
290
328
  catch (error) {
@@ -295,7 +333,7 @@ export function createContinuationHooks(client, directory, policySource, timings
295
333
  const current = sessions.get(sessionID);
296
334
  if (current !== undefined) {
297
335
  current.active = false;
298
- if (!summarized)
336
+ if (compactionStarted && !compactionSucceeded)
299
337
  current.promptPending = false;
300
338
  }
301
339
  }
@@ -311,6 +349,7 @@ export function createContinuationHooks(client, directory, policySource, timings
311
349
  }
312
350
  else if (!completed && state?.pendingRollover === true) {
313
351
  state.pendingRollover = false;
352
+ state.compactedRollover = false;
314
353
  state.promptPending = false;
315
354
  state.continueReport = undefined;
316
355
  warnRollover(sessionID, "retries-exhausted");
@@ -320,6 +359,7 @@ export function createContinuationHooks(client, directory, policySource, timings
320
359
  function queueRollover(sessionID, report, resume) {
321
360
  const state = stateFor(sessionID);
322
361
  state.pendingRollover = true;
362
+ state.compactedRollover = false;
323
363
  state.latestReport = report;
324
364
  state.continueReport = resume ? report : undefined;
325
365
  if (resume)
@@ -378,6 +418,12 @@ export function createContinuationHooks(client, directory, policySource, timings
378
418
  });
379
419
  if (!resolution.compact)
380
420
  return `SORTIE_CONTINUATION_REJECTED: ${resolution.reason}`;
421
+ if (client?.session?.summarize === undefined) {
422
+ return "SORTIE_CONTINUATION_REJECTED: capability-unavailable";
423
+ }
424
+ if (summarizeBody(state) === undefined) {
425
+ return "SORTIE_CONTINUATION_REJECTED: summarize-model-unavailable";
426
+ }
381
427
  // The direct capability and the marker fallback are mutually exclusive within one turn.
382
428
  state.directUsed = true;
383
429
  queueRollover(context.sessionID, "Tool-requested Sortie rollover. Preserve task identity, both manifests, validation history, " +
@@ -457,6 +503,15 @@ export function createContinuationHooks(client, directory, policySource, timings
457
503
  if (pending)
458
504
  output.enabled = false;
459
505
  },
506
+ observeModel(sessionID, model) {
507
+ if (!nonEmpty(model.providerID) || !nonEmpty(model.modelID))
508
+ return;
509
+ stateFor(sessionID).model = { providerID: model.providerID, modelID: model.modelID };
510
+ },
511
+ blocksTool(sessionID) {
512
+ const state = sessions.get(sessionID);
513
+ return state?.pendingRollover === true || state?.active === true || state?.promptPending === true;
514
+ },
460
515
  async sessionIdle(sessionID) {
461
516
  await runRollover(sessionID);
462
517
  },
@@ -1374,6 +1374,8 @@ export const SortieDogsPlugin = async (input, options) => {
1374
1374
  */
1375
1375
  await ensureLoaded();
1376
1376
  await loaded?.modelRoutingHook?.(chatInput, output);
1377
+ if (coordinatorOrigin)
1378
+ continuation.observeModel(chatInput.sessionID, output.message.model);
1377
1379
  },
1378
1380
  ...(reflectionStartup ? { "experimental.chat.system.transform": async (transformInput, transformOutput) => {
1379
1381
  if (!(await beginReflection(transformInput.sessionID)))
@@ -1433,8 +1435,12 @@ export const SortieDogsPlugin = async (input, options) => {
1433
1435
  await inspectSuccessfulRead(toolInput);
1434
1436
  },
1435
1437
  "tool.execute.before": async (toolInput, output) => {
1436
- if (isCoordinatorSession(toolInput.sessionID))
1438
+ if (isCoordinatorSession(toolInput.sessionID)) {
1439
+ if (continuation.blocksTool(toolInput.sessionID)) {
1440
+ throw new Error("SORTIE_ROLLOVER_PENDING: stop this turn and wait for compaction");
1441
+ }
1437
1442
  return;
1443
+ }
1438
1444
  const status = activeSessionStatus(toolInput.sessionID);
1439
1445
  if (status === "inactive")
1440
1446
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.2.14",
3
+ "version": "0.2.15",
4
4
  "description": "Bounded, validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",