@canonmsg/agent-sdk 8.1.0 → 8.2.0
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 +46 -0
- package/dist/canon-agent.js +122 -28
- package/dist/index.d.ts +2 -1
- package/dist/turn-streaming-request.d.ts +17 -0
- package/dist/turn-streaming-request.js +22 -0
- package/dist/turn-verbosity-option.d.ts +33 -0
- package/dist/turn-verbosity-option.js +23 -0
- package/dist/types.d.ts +34 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -57,6 +57,7 @@ The only runtime dependency is `@canonmsg/core`, which npm installs for you. Eve
|
|
|
57
57
|
| `runtimeControlSurface` | `'agent' \| 'host'` | `'agent'` | Runtime publishing surface. Use `host` when this SDK agent owns live runtime controls. |
|
|
58
58
|
| `runtimePrimitives` | `RuntimePrimitiveHandlers` | `undefined` | Optional typed primitive command handlers for descriptor-backed runtime commands |
|
|
59
59
|
| `sessionState` | `boolean` | `false` | Publish runtime-applied state to the canonical agent-session snapshot |
|
|
60
|
+
| `turnVerbosity` | `'verbose' \| 'quiet' \| 'auto'` or `{ direct?, group? }` | `'auto'` | How much of a turn's middle readers see. See [Turn verbosity](#turn-verbosity). |
|
|
60
61
|
|
|
61
62
|
### Optional runtime controls
|
|
62
63
|
|
|
@@ -197,6 +198,7 @@ The `message` event handler receives a context object with:
|
|
|
197
198
|
| `provenance` | `CanonRuntimeProvenance` | Canon-computed sender/conversation context for the latest inbound message in this batch |
|
|
198
199
|
| `turnContext` | `CanonTurnContextV2` | Compact structured turn context; fields are intentionally shaped by conversation type and sender type |
|
|
199
200
|
| `requestedTurnMode` | `string \| null` | Runtime turn mode the sender requested for this inbound turn, if any |
|
|
201
|
+
| `turnVerbosity` | `'verbose' \| 'quiet'` | Resolved emission mode for this turn — see [Turn verbosity](#turn-verbosity). Fixed for the whole turn |
|
|
200
202
|
| `requestApproval` | `(request) => Promise<ApprovalResult>` | Render a Canon approval card and wait for the decision. Fail-closed: returns `{ decision: 'deny' }` on any non-abort failure instead of throwing |
|
|
201
203
|
| `requestRuntimeInput` | `(request) => Promise<RuntimeInputResult>` | Render a Canon input card for clarification, sudo, or secret values |
|
|
202
204
|
| `requestCard` / `sendCard` | functions | Render a generic `canon.card.v1` rich card. `requestCard` blocks only on cards that carry an `actions` block; `sendCard` posts a display card |
|
|
@@ -472,11 +474,55 @@ While a handler runs, the SDK automatically publishes Canon turn state and clear
|
|
|
472
474
|
- `setStreaming(text)`
|
|
473
475
|
- `setTool(text)`
|
|
474
476
|
- `setWaitingInput(text?)`
|
|
477
|
+
- `noReply(reason?)`
|
|
478
|
+
|
|
479
|
+
`noReply()` is Canon's `no_reply` verb on the SDK side: end this turn without posting anything. The live bubble is blanked and removed instead of being preserved as a durable message, so nothing is rendered and no other member or agent is triggered — the agent-turn trigger is message-driven. Use it in groups when the handler decides it has nothing to add. `reason` is private: the SDK records that one was given, never the text.
|
|
480
|
+
|
|
481
|
+
It governs teardown only. Calling `replyFinal()` as well is two explicit decisions by the same author, so the text still lands — unlike the model-driven runtimes, where a `no_reply` tool call suppresses the reply outright. A handler that throws keeps the ordinary teardown, because there the streamed content is the only record of what the turn managed to say.
|
|
475
482
|
|
|
476
483
|
`setWaitingInput()` keeps the turn open in `waiting_input` and optionally sends a control message to the conversation so Canon clients can render “reply to continue” correctly.
|
|
477
484
|
|
|
478
485
|
`replyProgress()` is ephemeral by default: it updates the live RTDB turn preview without adding a permanent Firestore message. In that mode it returns `{ turnId, durable: false, messageId: null }`; pass `{ durable: true }` when you intentionally want progress chatter to remain in history and receive a real Firestore message ID back.
|
|
479
486
|
|
|
487
|
+
## Turn verbosity
|
|
488
|
+
|
|
489
|
+
By default an agent is **quiet in group conversations and verbose in direct chats**. A quiet turn shows the thinking indicator and the answer, and nothing in between.
|
|
490
|
+
|
|
491
|
+
```ts
|
|
492
|
+
const agent = new CanonAgent({
|
|
493
|
+
apiKey: process.env.CANON_API_KEY!,
|
|
494
|
+
environmentId: 'canon-prod-v1',
|
|
495
|
+
turnVerbosity: 'verbose', // scalar: applies everywhere
|
|
496
|
+
// turnVerbosity: { group: 'verbose' }, // object: override one conversation type
|
|
497
|
+
});
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
| Value | Effect |
|
|
501
|
+
|---|---|
|
|
502
|
+
| `'auto'` (default, same as omitting the option) | Verbose in direct chats, quiet in groups |
|
|
503
|
+
| `'verbose'` | Live turn state and the margin activity trail, everywhere |
|
|
504
|
+
| `'quiet'` | Thinking indicator and the final message only, everywhere |
|
|
505
|
+
| `{ direct?, group? }` | Overrides the named conversation type; the unnamed one keeps its default |
|
|
506
|
+
|
|
507
|
+
A conversation whose type Canon could not determine falls back to verbose, never to silence.
|
|
508
|
+
|
|
509
|
+
**Quiet suppresses**: every `/streaming` publication — the `'Thinking...'` seed and its keepalive, `turn.setThinking/setStreaming/setTool`, `turn.appendDelta`/`appendBlock`/segment updates, `turn.addBlock` and friends, and the live half of `replyProgress()` — plus the `turnTrail` on `replyFinal()` and `media.replyWithFile()`. Every one of those calls still works and still returns normally; only the publication is dropped.
|
|
510
|
+
|
|
511
|
+
**Quiet does not suppress**: the typing/thinking indicator (which stays up for the turn's whole working phase; while the turn is parked on an approval the clients suppress an agent's dots and the header line carries the state), turn state, `replyFinal()` including every part of a chunked reply, the partial-final notice, `media.replyWithFile()` itself, `turn.setWaitingInput()`'s note, `sendContextualMessage()`, `publishRuntimeActivity()`, approval/input/card requests, and their outcome receipts.
|
|
512
|
+
|
|
513
|
+
**`replyProgress(text, { durable: true })` still posts.** Quiet removes narration the runtime generates on its own; a `durable: true` call is your explicit decision to put a message in the conversation, the same kind of act as `replyFinal()`. Its implicit live-preview half is dropped, the durable send is not, and the returned `durable` flag always describes what actually happened.
|
|
514
|
+
|
|
515
|
+
`ctx.turnVerbosity` carries the resolved value into the handler, so a handler that would otherwise build an expensive live preview can skip it:
|
|
516
|
+
|
|
517
|
+
```ts
|
|
518
|
+
agent.on('message', async (ctx) => {
|
|
519
|
+
if (ctx.turnVerbosity === 'verbose') await ctx.turn.setThinking('Reading the repo…');
|
|
520
|
+
await ctx.replyFinal(await answer(ctx));
|
|
521
|
+
});
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
This is a developer setting. Canon never changes it, and users cannot set it per conversation.
|
|
525
|
+
|
|
480
526
|
### Long text
|
|
481
527
|
|
|
482
528
|
Canon caps a single message at 4 KB of UTF-8 text, and rejects anything longer outright. Two send paths split oversized text for you instead of failing:
|
package/dist/canon-agent.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { ApprovalManager, RuntimeRequestManager, runtimeInputDescriptor, runtimeCardDescriptor, CanonClient, ControlChannelPoller, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, isChunkedSendMessageError, normalizeRuntimeCommandDescriptors, normalizeTurnMetadata, reachOutToCanonContact, resolveCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, selectActiveSelfContexts, renderCanonHostInboundContent, resolveCanonRuntimeConnection, sendMessageWithRetryChunked, splitTextByUtf8Bytes, verifyCanonRuntimeConnection, } from '@canonmsg/core';
|
|
1
|
+
import { ApprovalManager, RuntimeRequestManager, runtimeInputDescriptor, runtimeCardDescriptor, CanonClient, ControlChannelPoller, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, isChunkedSendMessageError, normalizeRuntimeCommandDescriptors, normalizeTurnMetadata, normalizeTurnVerbosityConversationType, reachOutToCanonContact, resolveCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, resolveTurnVerbosity, selectActiveSelfContexts, renderCanonHostInboundContent, resolveCanonRuntimeConnection, sendMessageWithRetryChunked, shouldPublishTurnTrail, splitTextByUtf8Bytes, verifyCanonRuntimeConnection, } from '@canonmsg/core';
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { AuthManager } from './auth.js';
|
|
4
4
|
import { Debouncer } from './debouncer.js';
|
|
5
5
|
import { DEFAULT_RUNTIME_INPUT_TIMEOUT_MS, RUNTIME_INPUT_ID_PATTERN, buildRuntimeCardCreateArgs, normalizeResponseUserId, resolveRuntimeCardRouting, } from './runtime-card.js';
|
|
6
6
|
import { materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, uploadMediaFile, } from './media.js';
|
|
7
7
|
import { SessionManager } from './session-manager.js';
|
|
8
|
+
import { buildTurnStreamingRequest } from './turn-streaming-request.js';
|
|
9
|
+
import { selectConfiguredTurnVerbosity } from './turn-verbosity-option.js';
|
|
8
10
|
const AGENT_RUNTIME_HEARTBEAT_MS = 30_000;
|
|
9
11
|
const RUNTIME_CONTROL_POLL_INTERVAL_MS = 2_000;
|
|
10
12
|
const RUNTIME_PRIMITIVE_DEDUPE_TTL_MS = 5 * 60 * 1000;
|
|
@@ -1246,6 +1248,14 @@ export class CanonAgent {
|
|
|
1246
1248
|
const turnOpenedAt = Date.now();
|
|
1247
1249
|
let turnState = 'thinking';
|
|
1248
1250
|
let shouldPersistTurnState = false;
|
|
1251
|
+
// `ctx.turn.noReply()`: the handler chose to end this turn without posting.
|
|
1252
|
+
// Governs TEARDOWN only — `replyFinal` still sends, because in the SDK the
|
|
1253
|
+
// developer's two explicit calls are two explicit decisions.
|
|
1254
|
+
let deliberatelySilent = false;
|
|
1255
|
+
// Set when the handler threw or the turn was aborted. Deliberate silence
|
|
1256
|
+
// must never blank the live node on those paths: the streamed content is
|
|
1257
|
+
// all that survives a turn that died mid-flight.
|
|
1258
|
+
let turnEndedAbnormally = false;
|
|
1249
1259
|
let durableMessageSequence = 0;
|
|
1250
1260
|
const agentId = this.agentId;
|
|
1251
1261
|
const runtimeState = this.createRuntimeStatePublisher();
|
|
@@ -1287,17 +1297,32 @@ export class CanonAgent {
|
|
|
1287
1297
|
: {}),
|
|
1288
1298
|
})).catch(() => { });
|
|
1289
1299
|
};
|
|
1300
|
+
// Quiet mode is resolved HERE, once, and carried for the whole turn.
|
|
1301
|
+
//
|
|
1302
|
+
// It has to be here because everything downstream is already too late: the
|
|
1303
|
+
// controller's mode is fixed at construction two lines below, the
|
|
1304
|
+
// `'Thinking...'` seed lands before that, and the conversation fetch that
|
|
1305
|
+
// would otherwise name the conversation type is ~30 lines further down,
|
|
1306
|
+
// inside the try. Provenance already carries the type — the stream service
|
|
1307
|
+
// stamps it on every `message.created` frame — so the answer is available
|
|
1308
|
+
// before the first publish of any kind.
|
|
1309
|
+
//
|
|
1310
|
+
// Re-deriving it mid-turn is what must never happen: a turn that started
|
|
1311
|
+
// quiet and finished verbose would emit a trail nothing narrated.
|
|
1312
|
+
const inboundConversationType = normalizeTurnVerbosityConversationType(provenanceByMessageId?.get(messages[messages.length - 1]?.id ?? '')?.conversation?.type);
|
|
1313
|
+
const turnVerbosity = resolveTurnVerbosity({
|
|
1314
|
+
configured: selectConfiguredTurnVerbosity(this.options.turnVerbosity, inboundConversationType),
|
|
1315
|
+
conversationType: inboundConversationType,
|
|
1316
|
+
});
|
|
1290
1317
|
const turnOutput = createTurnOutputController({
|
|
1291
1318
|
turnId,
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
blocks: snapshot.blocks,
|
|
1300
|
-
}),
|
|
1319
|
+
// `'status'` keeps every controller call site working and drops only the
|
|
1320
|
+
// RTDB write, which covers all ~12 of them at once — the thinking seed
|
|
1321
|
+
// and its keepalive, `turn.appendDelta`/`appendBlock`/`setTool`, the
|
|
1322
|
+
// approval block, and `replyProgress`'s implicit live half. Blocks still
|
|
1323
|
+
// accumulate in memory, so the trail gate below is a SEPARATE decision.
|
|
1324
|
+
mode: turnVerbosity === 'quiet' ? 'status' : 'snapshot',
|
|
1325
|
+
writeSnapshot: (snapshot) => this.apiClient.setStreaming(buildTurnStreamingRequest({ conversationId, snapshot })),
|
|
1301
1326
|
clearSnapshot: () => this.apiClient.clearStreaming(conversationId),
|
|
1302
1327
|
});
|
|
1303
1328
|
const setLiveState = async (state, text, streamingStatus) => {
|
|
@@ -1307,6 +1332,36 @@ export class CanonAgent {
|
|
|
1307
1332
|
await turnOutput.setStatus(streamingStatus, text ?? '');
|
|
1308
1333
|
}
|
|
1309
1334
|
};
|
|
1335
|
+
/**
|
|
1336
|
+
* Put the turn back to work after an interaction request settles.
|
|
1337
|
+
*
|
|
1338
|
+
* Both halves matter: the dots were cleared when the turn parked (the
|
|
1339
|
+
* clients suppress an agent's dots on `waiting_input`), and `/turn-state`
|
|
1340
|
+
* plus the live node are still sitting on `waiting_input`. The error paths
|
|
1341
|
+
* need this as much as the success paths — a request that failed still
|
|
1342
|
+
* leaves the handler running, and a quiet turn has no live row to stand in
|
|
1343
|
+
* for the missing dots. Aborts still propagate: `setLiveState` re-checks
|
|
1344
|
+
* the signal, which is how an interrupt lands during cleanup.
|
|
1345
|
+
*/
|
|
1346
|
+
const resumeTurnFromWaiting = async () => {
|
|
1347
|
+
try {
|
|
1348
|
+
await this.typingSignals.start(conversationId, 'thinking');
|
|
1349
|
+
}
|
|
1350
|
+
catch { }
|
|
1351
|
+
await setLiveState('thinking', 'Thinking...', 'thinking');
|
|
1352
|
+
};
|
|
1353
|
+
if (turnVerbosity === 'quiet') {
|
|
1354
|
+
// A quiet turn never writes to `/streaming`, so it cannot rely on the
|
|
1355
|
+
// 'Thinking...' seed below to overwrite a node an earlier verbose turn
|
|
1356
|
+
// left behind — and a surviving node under the PREVIOUS turn's id would
|
|
1357
|
+
// sit there, invisible, for this whole turn, then be deleted at teardown:
|
|
1358
|
+
// `onStreamingCleared` would salvage it into a durable bubble landing
|
|
1359
|
+
// AFTER this turn's answer. Deleting at turn open puts that salvage back
|
|
1360
|
+
// where the design intends it, ahead of the answer. Both coding hosts do
|
|
1361
|
+
// the same, one for free (its seed clears unconditionally) and one
|
|
1362
|
+
// explicitly.
|
|
1363
|
+
await turnOutput.clear().catch(() => { });
|
|
1364
|
+
}
|
|
1310
1365
|
// Show thinking indicator and keep it alive (5s client-side expiry)
|
|
1311
1366
|
try {
|
|
1312
1367
|
await this.typingSignals.start(conversationId, 'thinking');
|
|
@@ -1353,7 +1408,11 @@ export class CanonAgent {
|
|
|
1353
1408
|
catch { }
|
|
1354
1409
|
throwIfAborted();
|
|
1355
1410
|
const sendOptions = withActiveSelfContext(options);
|
|
1356
|
-
|
|
1411
|
+
// Separate from the controller's mode on purpose: `'status'` silences
|
|
1412
|
+
// the live writes but still accumulates blocks, so `getFinalTrail()`
|
|
1413
|
+
// would happily hand back a full trail for a turn that narrated
|
|
1414
|
+
// nothing.
|
|
1415
|
+
const turnTrail = shouldPublishTurnTrail(turnVerbosity) ? turnOutput.getFinalTrail() : [];
|
|
1357
1416
|
const finalOptions = {
|
|
1358
1417
|
...sendOptions,
|
|
1359
1418
|
metadata: {
|
|
@@ -1396,6 +1455,14 @@ export class CanonAgent {
|
|
|
1396
1455
|
};
|
|
1397
1456
|
const replyProgress = async (text, options) => {
|
|
1398
1457
|
throwIfAborted();
|
|
1458
|
+
// Owner ruling D1: in quiet the IMPLICIT half of this call — the live
|
|
1459
|
+
// `/streaming` narration — is dropped by the controller's `'status'`
|
|
1460
|
+
// mode, while the turn state it also publishes stays. The EXPLICIT half
|
|
1461
|
+
// below does not: `durable: true` is a developer asking Canon to post a
|
|
1462
|
+
// message, structurally the same act as `replyFinal`, and silently
|
|
1463
|
+
// no-op'ing it would leave a caller reading `durable: true` from a send
|
|
1464
|
+
// that never happened. The result object therefore keeps describing
|
|
1465
|
+
// what actually occurred in both modes.
|
|
1399
1466
|
await setLiveState('streaming', text, 'streaming');
|
|
1400
1467
|
if (!options?.durable) {
|
|
1401
1468
|
return { turnId, durable: false, messageId: null };
|
|
@@ -1667,11 +1734,7 @@ export class CanonAgent {
|
|
|
1667
1734
|
});
|
|
1668
1735
|
}
|
|
1669
1736
|
catch { }
|
|
1670
|
-
|
|
1671
|
-
await this.typingSignals.start(conversationId, 'thinking');
|
|
1672
|
-
}
|
|
1673
|
-
catch { }
|
|
1674
|
-
await setLiveState('thinking', 'Thinking...', 'thinking');
|
|
1737
|
+
await resumeTurnFromWaiting();
|
|
1675
1738
|
return result;
|
|
1676
1739
|
}
|
|
1677
1740
|
catch (error) {
|
|
@@ -1679,6 +1742,10 @@ export class CanonAgent {
|
|
|
1679
1742
|
throw error;
|
|
1680
1743
|
}
|
|
1681
1744
|
shouldPersistTurnState = false;
|
|
1745
|
+
// The turn parked before the request went out and the handler is
|
|
1746
|
+
// about to carry on with a denial, so put it back to work — the
|
|
1747
|
+
// success path is not the only way out of `waiting_input`.
|
|
1748
|
+
await resumeTurnFromWaiting();
|
|
1682
1749
|
return { decision: 'deny' };
|
|
1683
1750
|
}
|
|
1684
1751
|
};
|
|
@@ -1761,11 +1828,7 @@ export class CanonAgent {
|
|
|
1761
1828
|
});
|
|
1762
1829
|
}
|
|
1763
1830
|
catch { }
|
|
1764
|
-
|
|
1765
|
-
await this.typingSignals.start(conversationId, 'thinking');
|
|
1766
|
-
}
|
|
1767
|
-
catch { }
|
|
1768
|
-
await setLiveState('thinking', 'Thinking...', 'thinking');
|
|
1831
|
+
await resumeTurnFromWaiting();
|
|
1769
1832
|
return result;
|
|
1770
1833
|
}
|
|
1771
1834
|
catch (error) {
|
|
@@ -1786,6 +1849,10 @@ export class CanonAgent {
|
|
|
1786
1849
|
throw error;
|
|
1787
1850
|
}
|
|
1788
1851
|
shouldPersistTurnState = false;
|
|
1852
|
+
// Parked, then failed rather than answered — the handler carries on
|
|
1853
|
+
// with the fallback result, so the turn has to look like it is
|
|
1854
|
+
// working again. The success path is not the only way out.
|
|
1855
|
+
await resumeTurnFromWaiting();
|
|
1789
1856
|
return result;
|
|
1790
1857
|
}
|
|
1791
1858
|
};
|
|
@@ -1888,11 +1955,7 @@ export class CanonAgent {
|
|
|
1888
1955
|
});
|
|
1889
1956
|
}
|
|
1890
1957
|
catch { }
|
|
1891
|
-
|
|
1892
|
-
await this.typingSignals.start(conversationId, 'thinking');
|
|
1893
|
-
}
|
|
1894
|
-
catch { }
|
|
1895
|
-
await setLiveState('thinking', 'Thinking...', 'thinking');
|
|
1958
|
+
await resumeTurnFromWaiting();
|
|
1896
1959
|
return result;
|
|
1897
1960
|
}
|
|
1898
1961
|
catch (error) {
|
|
@@ -1922,6 +1985,10 @@ export class CanonAgent {
|
|
|
1922
1985
|
throw error;
|
|
1923
1986
|
}
|
|
1924
1987
|
shouldPersistTurnState = false;
|
|
1988
|
+
// Parked, then failed rather than answered — the handler carries on
|
|
1989
|
+
// with the fallback result, so the turn has to look like it is
|
|
1990
|
+
// working again. The success path is not the only way out.
|
|
1991
|
+
await resumeTurnFromWaiting();
|
|
1925
1992
|
return result;
|
|
1926
1993
|
}
|
|
1927
1994
|
};
|
|
@@ -1934,7 +2001,8 @@ export class CanonAgent {
|
|
|
1934
2001
|
catch { }
|
|
1935
2002
|
throwIfAborted();
|
|
1936
2003
|
try {
|
|
1937
|
-
|
|
2004
|
+
// Same gate as `replyFinal` — this send IS a final, trail included.
|
|
2005
|
+
const turnTrail = shouldPublishTurnTrail(turnVerbosity) ? turnOutput.getFinalTrail() : [];
|
|
1938
2006
|
const result = await sendMediaFileMessage(this.apiClient, conversationId, filePath, text, {
|
|
1939
2007
|
...(options?.replyTo ? { replyTo: options.replyTo } : {}),
|
|
1940
2008
|
...(options?.replyToPosition != null
|
|
@@ -1989,6 +2057,7 @@ export class CanonAgent {
|
|
|
1989
2057
|
provenance,
|
|
1990
2058
|
turnContext,
|
|
1991
2059
|
requestedTurnMode,
|
|
2060
|
+
turnVerbosity,
|
|
1992
2061
|
requestApproval,
|
|
1993
2062
|
requestRuntimeInput,
|
|
1994
2063
|
requestCard,
|
|
@@ -2075,6 +2144,14 @@ export class CanonAgent {
|
|
|
2075
2144
|
clear: async () => {
|
|
2076
2145
|
await turnOutput.clear();
|
|
2077
2146
|
},
|
|
2147
|
+
noReply: async (reason) => {
|
|
2148
|
+
throwIfAborted();
|
|
2149
|
+
deliberatelySilent = true;
|
|
2150
|
+
// `reason` is handler-authored free text: record only that one was
|
|
2151
|
+
// given, never the text itself.
|
|
2152
|
+
console.error(`[canon-sdk] Turn chose no_reply for ${conversationId}`
|
|
2153
|
+
+ ` (reason: ${reason?.trim() ? 'given' : 'none'})`);
|
|
2154
|
+
},
|
|
2078
2155
|
setTool: async (text) => {
|
|
2079
2156
|
await writeTurn('tool');
|
|
2080
2157
|
await turnOutput.addBlock({
|
|
@@ -2119,6 +2196,7 @@ export class CanonAgent {
|
|
|
2119
2196
|
}
|
|
2120
2197
|
}
|
|
2121
2198
|
catch (err) {
|
|
2199
|
+
turnEndedAbnormally = true;
|
|
2122
2200
|
if (abortController.signal.aborted || isAbortLikeError(err)) {
|
|
2123
2201
|
await writeTurn('interrupted');
|
|
2124
2202
|
return;
|
|
@@ -2138,13 +2216,29 @@ export class CanonAgent {
|
|
|
2138
2216
|
this.activeTurns.delete(conversationId);
|
|
2139
2217
|
}
|
|
2140
2218
|
clearInterval(thinkingKeepalive);
|
|
2219
|
+
// Sequenced AFTER the keepalive stops, and with no handoff delay in front
|
|
2220
|
+
// of it: the 3.5 s keepalive would rewrite 'Thinking...' over a blank
|
|
2221
|
+
// scheduled earlier, and every other runtime retires the row and the
|
|
2222
|
+
// typing dots together the moment a turn goes silent. `replyFinal`'s
|
|
2223
|
+
// handoff window overlaps a durable message that has already landed;
|
|
2224
|
+
// here there is nothing to hand off to, and holding the indicator would
|
|
2225
|
+
// read as "started to answer, then gave up".
|
|
2226
|
+
const silentTeardown = deliberatelySilent && !turnEndedAbnormally;
|
|
2141
2227
|
// Always clear typing when done
|
|
2142
2228
|
try {
|
|
2143
2229
|
await this.typingSignals.clear(conversationId);
|
|
2144
2230
|
}
|
|
2145
2231
|
catch { }
|
|
2146
2232
|
try {
|
|
2147
|
-
|
|
2233
|
+
// Deliberate silence blanks the node (text '' AND an explicit empty
|
|
2234
|
+
// blocks array) before deleting it, so onStreamingCleared sees empty
|
|
2235
|
+
// content and does not salvage the turn's narration into a durable
|
|
2236
|
+
// bubble. A crashed or interrupted turn keeps the plain clear: there
|
|
2237
|
+
// the salvage is the only record of what the turn managed to say.
|
|
2238
|
+
if (silentTeardown)
|
|
2239
|
+
await turnOutput.blankAndClear();
|
|
2240
|
+
else
|
|
2241
|
+
await turnOutput.clear();
|
|
2148
2242
|
}
|
|
2149
2243
|
catch { }
|
|
2150
2244
|
if (runtimeState && !shouldPersistTurnState) {
|
package/dist/index.d.ts
CHANGED
|
@@ -6,5 +6,6 @@ export { SessionManager } from './session-manager.js';
|
|
|
6
6
|
export { DEFAULT_MEDIA_CACHE_DIR, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, uploadMediaFile, } from './media.js';
|
|
7
7
|
export type { AnthropicImageBlock, AnthropicImageMimeType, MaterializeMediaOptions, MaterializedCanonAttachment, MaterializedCanonReplyContext, ReplyWithFileOptions, UploadMediaFileOptions, } from './media.js';
|
|
8
8
|
export type { SessionConfig, Session } from './session-manager.js';
|
|
9
|
-
export type {
|
|
9
|
+
export type { CanonAgentTurnVerbosityOption } from './turn-verbosity-option.js';
|
|
10
|
+
export type { AgentContext, CanonGroupContext, CanonKnownRecentParticipant, CanonMembershipChange, CanonContactRequest, CanonMessage, CanonConversation, CanonConversationsPage, CanonConversationsPageOptions, CanonReplyContext, CanonSelfContext, CanonTurnContextV2, CanonRuntimeDescriptor, MessageUpdatedPayload, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, CreateConversationOptions, CreateConversationResult, DirectSessionSelection, TurnVerbosity, TurnVerbosityConfig, } from '@canonmsg/core';
|
|
10
11
|
export type { CanonAgentConnectionOptions, CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, ContactRequestHandler, FinalMessageResult, MessageHandler, MessageHandlerContext, MessageUpdatedHandler, ProgressMessageOptions, ProgressMessageResult, ReachOutOptions, ReachOutResult, RuntimeApprovalRequest, RuntimeInputRequest, RuntimeInputResult, RuntimeControlSurface, RuntimePrimitiveContext, RuntimePrimitiveHandler, RuntimePrimitiveHandlers, SessionInfo, SessionOptions, DeliveryMode, } from './types.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CanonClient, TurnOutputSnapshot } from '@canonmsg/core';
|
|
2
|
+
export type TurnStreamingRequest = Parameters<CanonClient['setStreaming']>[0];
|
|
3
|
+
/**
|
|
4
|
+
* Builds the `POST /streaming` body for one turn-output snapshot.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from the turn loop for one reason: `blocks` must be forwarded
|
|
7
|
+
* VERBATIM, empty array included. `POST /streaming` merges into the RTDB node,
|
|
8
|
+
* so an absent `blocks` key leaves the previous trail in place — and the
|
|
9
|
+
* `onStreamingCleared` trigger rebuilds salvage text out of block titles. A
|
|
10
|
+
* silent turn blanks the node with `text: ''` and `blocks: []`; drop the empty
|
|
11
|
+
* array on the way out and the turn the model declined to send comes back as a
|
|
12
|
+
* durable bubble made of its own tool-trail headings.
|
|
13
|
+
*/
|
|
14
|
+
export declare function buildTurnStreamingRequest(input: {
|
|
15
|
+
conversationId: string;
|
|
16
|
+
snapshot: TurnOutputSnapshot;
|
|
17
|
+
}): TurnStreamingRequest;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the `POST /streaming` body for one turn-output snapshot.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from the turn loop for one reason: `blocks` must be forwarded
|
|
5
|
+
* VERBATIM, empty array included. `POST /streaming` merges into the RTDB node,
|
|
6
|
+
* so an absent `blocks` key leaves the previous trail in place — and the
|
|
7
|
+
* `onStreamingCleared` trigger rebuilds salvage text out of block titles. A
|
|
8
|
+
* silent turn blanks the node with `text: ''` and `blocks: []`; drop the empty
|
|
9
|
+
* array on the way out and the turn the model declined to send comes back as a
|
|
10
|
+
* durable bubble made of its own tool-trail headings.
|
|
11
|
+
*/
|
|
12
|
+
export function buildTurnStreamingRequest(input) {
|
|
13
|
+
const { conversationId, snapshot } = input;
|
|
14
|
+
return {
|
|
15
|
+
conversationId,
|
|
16
|
+
text: snapshot.text,
|
|
17
|
+
status: snapshot.status,
|
|
18
|
+
messageId: snapshot.messageId,
|
|
19
|
+
turnId: snapshot.turnId,
|
|
20
|
+
blocks: snapshot.blocks,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type TurnVerbosityConfig, type TurnVerbosityConversationType } from '@canonmsg/core';
|
|
2
|
+
/**
|
|
3
|
+
* What a developer may write for `CanonAgentOptions.turnVerbosity`.
|
|
4
|
+
*
|
|
5
|
+
* The SDK is the one runtime where a per-conversation-type OBJECT is idiomatic:
|
|
6
|
+
* it has no argv and no config file, so `{ group: 'verbose' }` costs a
|
|
7
|
+
* developer nothing while the hosts would have to invent a flag grammar for the
|
|
8
|
+
* same thing. Everything else — the vocabulary, the defaults, `'auto'` ≡ unset
|
|
9
|
+
* — stays in core's `resolveTurnVerbosity`, which this folds into rather than
|
|
10
|
+
* reimplements.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately NOT promoted to `@canonmsg/core`: the Claude host, the Codex
|
|
13
|
+
* host, OpenClaw and Hermes all take a scalar (a CLI flag, an env var or a
|
|
14
|
+
* config-file string), so core would grow a shape with exactly one consumer.
|
|
15
|
+
*/
|
|
16
|
+
export type CanonAgentTurnVerbosityOption = TurnVerbosityConfig | {
|
|
17
|
+
/** Applies to direct chats — Hermes's `'dm'` spelling included. */
|
|
18
|
+
direct?: TurnVerbosityConfig;
|
|
19
|
+
group?: TurnVerbosityConfig;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Picks the configured value this turn's conversation type is subject to, in
|
|
23
|
+
* the shape `resolveTurnVerbosity` takes.
|
|
24
|
+
*
|
|
25
|
+
* `null` means "nothing configured for this type" and lets core's per-type
|
|
26
|
+
* default decide — which is also what an `'auto'` entry and an unrecognized
|
|
27
|
+
* string produce, so an object that names only `group` leaves DMs on the
|
|
28
|
+
* default rather than forcing them anywhere. A conversation type that resolves
|
|
29
|
+
* to `'unknown'` (the fetch failed, or provenance was absent) matches no key
|
|
30
|
+
* and therefore falls through to the fail-open default too: an object form
|
|
31
|
+
* cannot silence a turn whose shape Canon could not name.
|
|
32
|
+
*/
|
|
33
|
+
export declare function selectConfiguredTurnVerbosity(option: CanonAgentTurnVerbosityOption | null | undefined, conversationType: TurnVerbosityConversationType | null | undefined): TurnVerbosityConfig | null;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { normalizeTurnVerbosityConversationType, parseTurnVerbosityConfig, } from '@canonmsg/core';
|
|
2
|
+
/**
|
|
3
|
+
* Picks the configured value this turn's conversation type is subject to, in
|
|
4
|
+
* the shape `resolveTurnVerbosity` takes.
|
|
5
|
+
*
|
|
6
|
+
* `null` means "nothing configured for this type" and lets core's per-type
|
|
7
|
+
* default decide — which is also what an `'auto'` entry and an unrecognized
|
|
8
|
+
* string produce, so an object that names only `group` leaves DMs on the
|
|
9
|
+
* default rather than forcing them anywhere. A conversation type that resolves
|
|
10
|
+
* to `'unknown'` (the fetch failed, or provenance was absent) matches no key
|
|
11
|
+
* and therefore falls through to the fail-open default too: an object form
|
|
12
|
+
* cannot silence a turn whose shape Canon could not name.
|
|
13
|
+
*/
|
|
14
|
+
export function selectConfiguredTurnVerbosity(option, conversationType) {
|
|
15
|
+
if (option === null || option === undefined)
|
|
16
|
+
return null;
|
|
17
|
+
if (typeof option === 'string')
|
|
18
|
+
return parseTurnVerbosityConfig(option);
|
|
19
|
+
const key = normalizeTurnVerbosityConversationType(conversationType);
|
|
20
|
+
if (key === 'unknown')
|
|
21
|
+
return null;
|
|
22
|
+
return parseTurnVerbosityConfig(option[key]);
|
|
23
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -62,6 +62,19 @@ export interface TurnController {
|
|
|
62
62
|
replaceSnapshot: (text: string) => Promise<void>;
|
|
63
63
|
flush: () => Promise<void>;
|
|
64
64
|
clear: () => Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* End this turn without posting anything (`canon.verbs.v1` `no_reply`). The
|
|
67
|
+
* live bubble is blanked and removed instead of being salvaged into a durable
|
|
68
|
+
* message, so nothing is rendered and no other member or agent is triggered —
|
|
69
|
+
* the agent-turn trigger is message-driven.
|
|
70
|
+
*
|
|
71
|
+
* `reason` is private: recorded as present or absent, never sent or rendered.
|
|
72
|
+
*
|
|
73
|
+
* Unlike the model-driven runtimes, this does NOT gate `replyFinal`. Calling
|
|
74
|
+
* both is two explicit decisions by the same author, so the text still lands;
|
|
75
|
+
* call `noReply` when the handler has decided not to answer at all.
|
|
76
|
+
*/
|
|
77
|
+
noReply: (reason?: string) => Promise<void>;
|
|
65
78
|
setTool: (text: string) => Promise<void>;
|
|
66
79
|
setWaitingInput: (text?: string) => Promise<void>;
|
|
67
80
|
}
|
|
@@ -179,6 +192,14 @@ export interface MessageHandlerContext {
|
|
|
179
192
|
turnContext: CanonTurnContextV2;
|
|
180
193
|
/** Runtime turn mode requested by the sender for this inbound turn, if any. */
|
|
181
194
|
requestedTurnMode: string | null;
|
|
195
|
+
/**
|
|
196
|
+
* How much of this turn's middle the reader sees, already resolved from
|
|
197
|
+
* `CanonAgentOptions.turnVerbosity` and the conversation type — groups are
|
|
198
|
+
* quiet by default, DMs verbose. In `'quiet'` the SDK publishes no
|
|
199
|
+
* `/streaming` narration and attaches no turn trail to the final, so a
|
|
200
|
+
* handler can skip building either. It stays fixed for the whole turn.
|
|
201
|
+
*/
|
|
202
|
+
turnVerbosity: import('@canonmsg/core').TurnVerbosity;
|
|
182
203
|
/**
|
|
183
204
|
* Ask the triggering human to approve a native runtime action. This only
|
|
184
205
|
* renders Canon's inline approval card; runtimes must explicitly wait for
|
|
@@ -299,6 +320,19 @@ export interface CanonAgentOptions extends CanonAgentConnectionOptions {
|
|
|
299
320
|
* Turn-state reporting is automatic while handlers run.
|
|
300
321
|
*/
|
|
301
322
|
sessionState?: boolean;
|
|
323
|
+
/**
|
|
324
|
+
* How much of a turn's middle the reader sees. Unset (or `'auto'`) resolves
|
|
325
|
+
* per turn from the conversation type: DMs verbose, groups quiet. A scalar
|
|
326
|
+
* overrides everywhere; the object form overrides one conversation type and
|
|
327
|
+
* leaves the other on its default.
|
|
328
|
+
*
|
|
329
|
+
* `'quiet'` drops the live `/streaming` narration and the final's margin
|
|
330
|
+
* turn trail. It never touches the typing indicator, the turn state, the
|
|
331
|
+
* final message, failure notices, or interaction cards and their receipts —
|
|
332
|
+
* and an explicit `ctx.replyProgress(text, { durable: true })` still posts,
|
|
333
|
+
* because a developer's explicit send is not runtime narration.
|
|
334
|
+
*/
|
|
335
|
+
turnVerbosity?: import('./turn-verbosity-option.js').CanonAgentTurnVerbosityOption;
|
|
302
336
|
}
|
|
303
337
|
export type ContactRequestHandler = (request: import('@canonmsg/core').CanonContactRequest) => void | Promise<void>;
|
|
304
338
|
export type ContactAddedHandler = (contact: import('@canonmsg/core').ContactAddedPayload) => void | Promise<void>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/agent-sdk",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.2.0",
|
|
4
4
|
"description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"node": ">=18.0.0"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@canonmsg/core": "^
|
|
31
|
+
"@canonmsg/core": "^10.0.0"
|
|
32
32
|
},
|
|
33
33
|
"publishConfig": {
|
|
34
34
|
"access": "public"
|