@guuey/agent-client 0.4.0 → 0.5.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 +39 -5
- package/dist/error-codes.d.ts +29 -0
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +27 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -9
- package/dist/invoke-turn.d.ts +101 -0
- package/dist/invoke-turn.d.ts.map +1 -0
- package/dist/invoke-turn.js +124 -0
- package/dist/react.d.ts +1 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +1 -1
- package/dist/saturation-retry.d.ts +41 -0
- package/dist/saturation-retry.d.ts.map +1 -1
- package/dist/saturation-retry.js +78 -6
- package/dist/transport.d.ts +90 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +189 -0
- package/dist/types.d.ts +73 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/useAgentInvoke.d.ts +28 -0
- package/dist/useAgentInvoke.d.ts.map +1 -1
- package/dist/useAgentInvoke.js +281 -90
- package/dist/web-adapters.d.ts +1 -11
- package/dist/web-adapters.d.ts.map +1 -1
- package/dist/web-adapters.js +9 -121
- package/package.json +8 -2
- package/src/error-codes.ts +31 -0
- package/src/index.ts +33 -9
- package/src/invoke-turn.ts +187 -0
- package/src/react.ts +7 -1
- package/src/saturation-retry.ts +103 -6
- package/src/transport.ts +260 -0
- package/src/types.ts +74 -1
- package/src/useAgentInvoke.ts +271 -89
- package/src/web-adapters.ts +10 -150
package/README.md
CHANGED
|
@@ -15,12 +15,13 @@ injected as adapters, so the same core runs on web (Next.js) and React Native.
|
|
|
15
15
|
npm install @guuey/agent-client
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
##
|
|
18
|
+
## Three entry points
|
|
19
19
|
|
|
20
|
-
| Import
|
|
21
|
-
|
|
|
22
|
-
| `@guuey/agent-client`
|
|
23
|
-
| `@guuey/agent-client/react`
|
|
20
|
+
| Import | Contents | React? |
|
|
21
|
+
| ------------------------------- | ---------------------------------------------------------------------------------------------------- | ------ |
|
|
22
|
+
| `@guuey/agent-client` | SSE helpers, `invokeTurn`, the thread-history reader, `createWebAdapters`, and all public types. | No |
|
|
23
|
+
| `@guuey/agent-client/react` | The `useAgentInvoke` hook (+ `applyHistoryResult`). | Yes |
|
|
24
|
+
| `@guuey/agent-client/transport` | Only the invoke transport + guest-identity pieces — zero `@guuey/mcp-apps-host` in the import graph. | No |
|
|
24
25
|
|
|
25
26
|
The root subpath is React-free — importing it never pulls React in. React is a
|
|
26
27
|
**required peer** (`react >=18`) because the `./react` subpath needs it; if you
|
|
@@ -67,6 +68,39 @@ composer re-enables. The full vocabulary is documented at
|
|
|
67
68
|
On React Native, supply your own adapters (AsyncStorage + an `expo/fetch`
|
|
68
69
|
transport) in place of `createWebAdapters` — the hook's contract is identical.
|
|
69
70
|
|
|
71
|
+
## Driving a turn without React
|
|
72
|
+
|
|
73
|
+
`invokeTurn` is the same wire walk as the hook, as a pure async generator —
|
|
74
|
+
for a Node harness, a game loop, or any host with its own turn state machine.
|
|
75
|
+
Its event stream is also the observation channel: tool results arrive as
|
|
76
|
+
typed `tool.done` AgEvents, so telemetry is a filter, not a callback API.
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { invokeTurn, toInvokeUrl, fetchStreamTransport } from "@guuey/agent-client";
|
|
80
|
+
|
|
81
|
+
const req = {
|
|
82
|
+
url: toInvokeUrl(endpointUrl),
|
|
83
|
+
body: { input, clientMessageId: crypto.randomUUID() },
|
|
84
|
+
signal: controller.signal,
|
|
85
|
+
};
|
|
86
|
+
// `getBearer` is resolved per attempt — a retry re-reads a fresh token.
|
|
87
|
+
const transport = (r) => fetchStreamTransport(r, null, null, { getBearer });
|
|
88
|
+
|
|
89
|
+
for await (const ev of invokeTurn(req, transport)) {
|
|
90
|
+
if (ev.kind !== "message") continue;
|
|
91
|
+
for (const agEvent of ev.agEvents) {
|
|
92
|
+
if (agEvent.type === "tool.done") {
|
|
93
|
+
telemetry.record(agEvent.toolCallId, agEvent.outcome ?? "ok");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
render(ev.assistantText); // full folded text — no delta bookkeeping
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The transport retries cold-start 503s (the post-redeploy window) and
|
|
101
|
+
`POD_SATURATED` refusals by itself, never after the first streamed byte;
|
|
102
|
+
tune or disable via its `coldStartRetry` option.
|
|
103
|
+
|
|
70
104
|
## React Native / Metro
|
|
71
105
|
|
|
72
106
|
Both entry points declare a `react-native` export condition that points at the
|
package/dist/error-codes.d.ts
CHANGED
|
@@ -27,6 +27,13 @@ export declare const AGENT_ERROR_CODES: {
|
|
|
27
27
|
readonly INVALID_REQUEST: "INVALID_REQUEST";
|
|
28
28
|
/** The builder turned anonymous access off for this agent. */
|
|
29
29
|
readonly GUEST_ACCESS_DISABLED: "GUEST_ACCESS_DISABLED";
|
|
30
|
+
/**
|
|
31
|
+
* The agent's own definition declares `auth: 'required'` and the caller is
|
|
32
|
+
* anonymous — sign in and retry with a bearer. The snapshot-declared twin of
|
|
33
|
+
* {@link AGENT_ERROR_CODES.GUEST_ACCESS_DISABLED} (the app-record runtime
|
|
34
|
+
* override); either gate can refuse.
|
|
35
|
+
*/
|
|
36
|
+
readonly AUTH_REQUIRED: "AUTH_REQUIRED";
|
|
30
37
|
/** The caller (or the app) is out of plan allowance — the upgrade prompt. */
|
|
31
38
|
readonly QUOTA_EXCEEDED: "QUOTA_EXCEEDED";
|
|
32
39
|
/** The app hit its builder-set managed spend cap. */
|
|
@@ -55,4 +62,26 @@ export declare const AGENT_ERROR_CODES: {
|
|
|
55
62
|
};
|
|
56
63
|
/** One of the pod's wire codes — see {@link AGENT_ERROR_CODES}. */
|
|
57
64
|
export type AgentErrorCode = (typeof AGENT_ERROR_CODES)[keyof typeof AGENT_ERROR_CODES];
|
|
65
|
+
/**
|
|
66
|
+
* CLIENT-originated failure codes — minted by THIS SDK, never by the pod.
|
|
67
|
+
*
|
|
68
|
+
* Deliberately a SEPARATE constant from {@link AGENT_ERROR_CODES}: that
|
|
69
|
+
* object is a transcribed mirror of the runtime's wire vocabulary, guarded by
|
|
70
|
+
* the runtime-side `agent-client-codes.sync.test.ts` — adding a code the pod
|
|
71
|
+
* never emits there would both break the sync guard and lie about the wire.
|
|
72
|
+
* These codes surface through the SAME `errorCode` channel (it is a plain
|
|
73
|
+
* `string` for exactly this kind of growth), so consumers branch the same
|
|
74
|
+
* way; the split exists so each vocabulary keeps one honest owner.
|
|
75
|
+
*/
|
|
76
|
+
export declare const CLIENT_ERROR_CODES: {
|
|
77
|
+
/**
|
|
78
|
+
* The SSE stream went byte-silent mid-turn and bounded history probes never
|
|
79
|
+
* found the finished reply (guuey#192's stall watchdog giving up). The turn
|
|
80
|
+
* is over (`status` returns to `ready`); a retry or a reload may still find
|
|
81
|
+
* the reply if the backend completes later.
|
|
82
|
+
*/
|
|
83
|
+
readonly STREAM_STALLED: "STREAM_STALLED";
|
|
84
|
+
};
|
|
85
|
+
/** One of this SDK's client-originated codes — see {@link CLIENT_ERROR_CODES}. */
|
|
86
|
+
export type ClientErrorCode = (typeof CLIENT_ERROR_CODES)[keyof typeof CLIENT_ERROR_CODES];
|
|
58
87
|
//# sourceMappingURL=error-codes.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"error-codes.d.ts","sourceRoot":"","sources":["../src/error-codes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,iBAAiB;IAC5B,yDAAyD;;IAEzD,gDAAgD;;IAEhD,8DAA8D;;IAE9D,6EAA6E;;IAE7E,qDAAqD;;IAErD;;;;OAIG;;IAEH;;;;;OAKG;;IAEH,oFAAoF;;IAEpF,qDAAqD;;IAErD,iEAAiE;;IAEjE,gCAAgC;;CAExB,CAAC;AAEX,mEAAmE;AACnE,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,iBAAiB,CAAC,CAAC,MAAM,OAAO,iBAAiB,CAAC,CAAC"}
|
|
1
|
+
{"version":3,"file":"error-codes.d.ts","sourceRoot":"","sources":["../src/error-codes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,iBAAiB;IAC5B,yDAAyD;;IAEzD,gDAAgD;;IAEhD,8DAA8D;;IAE9D;;;;;OAKG;;IAEH,6EAA6E;;IAE7E,qDAAqD;;IAErD;;;;OAIG;;IAEH;;;;;OAKG;;IAEH,oFAAoF;;IAEpF,qDAAqD;;IAErD,iEAAiE;;IAEjE,gCAAgC;;CAExB,CAAC;AAEX,mEAAmE;AACnE,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,iBAAiB,CAAC,CAAC,MAAM,OAAO,iBAAiB,CAAC,CAAC;AAExF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,kBAAkB;IAC7B;;;;;OAKG;;CAEK,CAAC;AAEX,kFAAkF;AAClF,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,OAAO,kBAAkB,CAAC,CAAC"}
|
package/dist/error-codes.js
CHANGED
|
@@ -27,6 +27,13 @@ export const AGENT_ERROR_CODES = {
|
|
|
27
27
|
INVALID_REQUEST: "INVALID_REQUEST",
|
|
28
28
|
/** The builder turned anonymous access off for this agent. */
|
|
29
29
|
GUEST_ACCESS_DISABLED: "GUEST_ACCESS_DISABLED",
|
|
30
|
+
/**
|
|
31
|
+
* The agent's own definition declares `auth: 'required'` and the caller is
|
|
32
|
+
* anonymous — sign in and retry with a bearer. The snapshot-declared twin of
|
|
33
|
+
* {@link AGENT_ERROR_CODES.GUEST_ACCESS_DISABLED} (the app-record runtime
|
|
34
|
+
* override); either gate can refuse.
|
|
35
|
+
*/
|
|
36
|
+
AUTH_REQUIRED: "AUTH_REQUIRED",
|
|
30
37
|
/** The caller (or the app) is out of plan allowance — the upgrade prompt. */
|
|
31
38
|
QUOTA_EXCEEDED: "QUOTA_EXCEEDED",
|
|
32
39
|
/** The app hit its builder-set managed spend cap. */
|
|
@@ -53,3 +60,23 @@ export const AGENT_ERROR_CODES = {
|
|
|
53
60
|
/** Unclassified pod failure. */
|
|
54
61
|
INTERNAL: "INTERNAL",
|
|
55
62
|
};
|
|
63
|
+
/**
|
|
64
|
+
* CLIENT-originated failure codes — minted by THIS SDK, never by the pod.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately a SEPARATE constant from {@link AGENT_ERROR_CODES}: that
|
|
67
|
+
* object is a transcribed mirror of the runtime's wire vocabulary, guarded by
|
|
68
|
+
* the runtime-side `agent-client-codes.sync.test.ts` — adding a code the pod
|
|
69
|
+
* never emits there would both break the sync guard and lie about the wire.
|
|
70
|
+
* These codes surface through the SAME `errorCode` channel (it is a plain
|
|
71
|
+
* `string` for exactly this kind of growth), so consumers branch the same
|
|
72
|
+
* way; the split exists so each vocabulary keeps one honest owner.
|
|
73
|
+
*/
|
|
74
|
+
export const CLIENT_ERROR_CODES = {
|
|
75
|
+
/**
|
|
76
|
+
* The SSE stream went byte-silent mid-turn and bounded history probes never
|
|
77
|
+
* found the finished reply (guuey#192's stall watchdog giving up). The turn
|
|
78
|
+
* is over (`status` returns to `ready`); a retry or a reload may still find
|
|
79
|
+
* the reply if the backend completes later.
|
|
80
|
+
*/
|
|
81
|
+
STREAM_STALLED: "STREAM_STALLED",
|
|
82
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
export { parseSseEvents, extractAssistantText, reduceAssistantText, stringField, parseConsentRequest, parseLinkRequest, type ParsedSseEvent, } from "./sse.js";
|
|
2
2
|
export { dismissLinkPrompt } from "./link-prompt.js";
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
3
|
+
export { invokeTurn, toInvokeUrl, type InvokeTurnEvent } from "./invoke-turn.js";
|
|
4
|
+
export { createUiActionRelay, type CreateUiActionRelayOptions, createUiResourceReader, type CreateUiResourceReaderOptions, createWebAdapters, localStorageThreadStore, webGenerateId, type CreateWebAdaptersOptions, } from "./web-adapters.js";
|
|
5
|
+
export { fetchStreamTransport, sendableGuestSecret, GUEST_HEADER, withActivityObserver, type FetchStreamTransportOptions, } from "./transport.js";
|
|
6
|
+
export { withSaturationRetry, withColdStartRetry, parseRetryAfterSeconds, type SaturationRetryOptions, type ColdStartRetryOptions, } from "./saturation-retry.js";
|
|
5
7
|
export { AgentResponseError } from "./errors.js";
|
|
6
|
-
export { AGENT_ERROR_CODES, type AgentErrorCode } from "./error-codes.js";
|
|
8
|
+
export { AGENT_ERROR_CODES, type AgentErrorCode, CLIENT_ERROR_CODES, type ClientErrorCode, } from "./error-codes.js";
|
|
7
9
|
export { fetchThreadHistory, threadHistoryRowsToMessages, threadHistoryRowsToCards, HistoryUnauthorizedError, type ThreadHistoryRow, type ThreadHistoryFetchOptions, } from "./history.js";
|
|
8
10
|
export { ingestMessageFrame } from "./blocks.js";
|
|
9
11
|
export { sortHistoryCards, toolNameFor } from "./history.js";
|
|
12
|
+
export { Reducer } from "@silverprotocol/core";
|
|
10
13
|
export type { AgEvent, AgReduceResult, AgMessage, AgBlock } from "@silverprotocol/core";
|
|
11
|
-
export type { AgentMessage, HistoryCard, ProfileConsentRequest, ProfileLinkRequest, ThreadIdStore, GenerateId, InvokeRequest, InvokeTransport, AgentInvokeAdapters, AgentInvokeHistoryAdapter, AgentInvokeStatus, HistoryLoadResult, UseAgentInvokeOptions, UseAgentInvokeReturn, } from "./types.js";
|
|
14
|
+
export type { AgentMessage, HistoryCard, ProfileConsentRequest, ProfileLinkRequest, ThreadIdStore, GenerateId, InvokeRequest, InvokeTransport, AgentInvokeAdapters, AgentInvokeHistoryAdapter, AgentInvokeStatus, HistoryLoadResult, StallRecoveryOptions, UseAgentInvokeOptions, UseAgentInvokeReturn, } from "./types.js";
|
|
12
15
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,oBAAoB,EACpB,mBAAmB,EACnB,WAAW,EACX,mBAAmB,EACnB,gBAAgB,EAChB,KAAK,cAAc,GACpB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,oBAAoB,EACpB,mBAAmB,EACnB,WAAW,EACX,mBAAmB,EACnB,gBAAgB,EAChB,KAAK,cAAc,GACpB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAGrD,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACjF,OAAO,EACL,mBAAmB,EACnB,KAAK,0BAA0B,EAC/B,sBAAsB,EACtB,KAAK,6BAA6B,EAClC,iBAAiB,EACjB,uBAAuB,EACvB,aAAa,EACb,KAAK,wBAAwB,GAC9B,MAAM,mBAAmB,CAAC;AAK3B,OAAO,EACL,oBAAoB,EACpB,mBAAmB,EACnB,YAAY,EACZ,oBAAoB,EACpB,KAAK,2BAA2B,GACjC,MAAM,gBAAgB,CAAC;AAOxB,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,sBAAsB,EACtB,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,GAC3B,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAGjD,OAAO,EACL,iBAAiB,EACjB,KAAK,cAAc,EACnB,kBAAkB,EAClB,KAAK,eAAe,GACrB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,kBAAkB,EAClB,2BAA2B,EAC3B,wBAAwB,EACxB,wBAAwB,EACxB,KAAK,gBAAgB,EACrB,KAAK,yBAAyB,GAC/B,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAKjD,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAM7D,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAC/C,YAAY,EAAE,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AACxF,YAAY,EACV,YAAY,EACZ,WAAW,EACX,qBAAqB,EACrB,kBAAkB,EAClB,aAAa,EACb,UAAU,EACV,aAAa,EACb,eAAe,EACf,mBAAmB,EACnB,yBAAyB,EACzB,iBAAiB,EACjB,iBAAiB,EACjB,oBAAoB,EACpB,qBAAqB,EACrB,oBAAoB,GACrB,MAAM,YAAY,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
export { parseSseEvents, extractAssistantText, reduceAssistantText, stringField, parseConsentRequest, parseLinkRequest, } from "./sse.js";
|
|
2
2
|
export { dismissLinkPrompt } from "./link-prompt.js";
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
3
|
+
// One agent turn as a pure async generator — the wire walk `useAgentInvoke`
|
|
4
|
+
// wraps, for hosts that drive their own turn state machine (guuey#186 G5).
|
|
5
|
+
export { invokeTurn, toInvokeUrl } from "./invoke-turn.js";
|
|
6
|
+
export { createUiActionRelay, createUiResourceReader, createWebAdapters, localStorageThreadStore, webGenerateId, } from "./web-adapters.js";
|
|
7
|
+
// The invoke transport + guest-identity wire pieces, in their own
|
|
8
|
+
// mcp-apps-host-free module. Consumers that want ONLY this graph (no
|
|
9
|
+
// host-role card layer riding along) import `@guuey/agent-client/transport`
|
|
10
|
+
// instead of the barrel — see that module's docblock (guuey#186 G2).
|
|
11
|
+
export { fetchStreamTransport, sendableGuestSecret, GUEST_HEADER, withActivityObserver, } from "./transport.js";
|
|
12
|
+
// The invoke-refusal retry wrappers, transport-agnostic: a host that brings
|
|
13
|
+
// its own `fetch` (Portal's React-Native transport) wraps them to wear the
|
|
14
|
+
// same semantics as the web transport instead of hand-rolling second copies.
|
|
15
|
+
// `parseRetryAfterSeconds` ships with them because filling
|
|
16
|
+
// `AgentResponseError.retryAfterSeconds` the same way is what makes the
|
|
17
|
+
// wrappers honour the pod's hint.
|
|
18
|
+
export { withSaturationRetry, withColdStartRetry, parseRetryAfterSeconds, } from "./saturation-retry.js";
|
|
11
19
|
export { AgentResponseError } from "./errors.js";
|
|
12
20
|
// The pod's wire-code vocabulary, mirrored — branch on these instead of
|
|
13
21
|
// re-typing the string literals (see the module docblock for the sync guard).
|
|
14
|
-
export { AGENT_ERROR_CODES } from "./error-codes.js";
|
|
22
|
+
export { AGENT_ERROR_CODES, CLIENT_ERROR_CODES, } from "./error-codes.js";
|
|
15
23
|
export { fetchThreadHistory, threadHistoryRowsToMessages, threadHistoryRowsToCards, HistoryUnauthorizedError, } from "./history.js";
|
|
16
24
|
export { ingestMessageFrame } from "./blocks.js";
|
|
17
25
|
// Pure block-walk / resource-narrowing helpers for a block-preserving renderer
|
|
@@ -19,3 +27,9 @@ export { ingestMessageFrame } from "./blocks.js";
|
|
|
19
27
|
// Transcript labeling/ordering helpers (mount narrowing itself moved to
|
|
20
28
|
// @guuey/mcp-apps-host — the SEP-1865 Host role package; import it directly).
|
|
21
29
|
export { sortHistoryCards, toolNameFor } from "./history.js";
|
|
30
|
+
// Re-export the AgJSON types the block-preserving transcript surfaces, so
|
|
31
|
+
// consumers can name `reduceResult` / block types without a direct
|
|
32
|
+
// `@silverprotocol/core` import — and the `Reducer` CLASS beside them, so a
|
|
33
|
+
// host folding `invokeTurn`'s agEvents outside the hook builds its transcript
|
|
34
|
+
// on the same terms (the types alone forced the direct dep back, guuey#186 G4).
|
|
35
|
+
export { Reducer } from "@silverprotocol/core";
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* invokeTurn — one agent turn as a pure async generator (guuey#186 G5).
|
|
3
|
+
*
|
|
4
|
+
* The per-turn loop `useAgentInvoke` runs — SSE accumulate → event switch →
|
|
5
|
+
* cumulative text fold → AgJSON block ingest — used to exist only fused to
|
|
6
|
+
* React state inside the hook, so a host with its own turn state machine
|
|
7
|
+
* (a game loop, a native view model, a server-side driver) had to re-walk
|
|
8
|
+
* the wire switch by hand. This module IS that loop, wire-in / semantics-out
|
|
9
|
+
* and React-free: feed it the request and a transport, iterate
|
|
10
|
+
* {@link InvokeTurnEvent}s. The hook is a thin wrapper that maps each event
|
|
11
|
+
* onto its state setters — behaviour-identical, one walk of the switch,
|
|
12
|
+
* owned here.
|
|
13
|
+
*
|
|
14
|
+
* Turn-scoped by design: the generator owns the CUMULATIVE assistant text
|
|
15
|
+
* for this turn (every `message` event carries the full folded text so far,
|
|
16
|
+
* not a delta). Cross-turn state stays with the caller — notably the AgJSON
|
|
17
|
+
* `Reducer`, which folds an entire conversation: this generator yields each
|
|
18
|
+
* frame's validated `agEvents` and never touches a reducer.
|
|
19
|
+
*
|
|
20
|
+
* Transport failures (e.g. `AgentResponseError` on a pre-stream refusal)
|
|
21
|
+
* propagate out of iteration — catch around the `for await`, exactly as the
|
|
22
|
+
* hook does. Unknown SSE events yield nothing, matching the hook's silent
|
|
23
|
+
* fall-through, so new wire events are additive for every consumer.
|
|
24
|
+
*/
|
|
25
|
+
import type { AgEvent } from "@silverprotocol/core";
|
|
26
|
+
import type { AgentInvokeStatus, InvokeRequest, InvokeTransport, ProfileConsentRequest, ProfileLinkRequest } from "./types.js";
|
|
27
|
+
/**
|
|
28
|
+
* One semantic step of a turn. Field conventions:
|
|
29
|
+
*
|
|
30
|
+
* - `message.status` / `message.activeTool` are ABSENT (not null) when the
|
|
31
|
+
* frame implies no change — apply them only when present, so an unknown
|
|
32
|
+
* frame type leaves your state machine untouched (the hook's exact rule:
|
|
33
|
+
* only `tool.start`/`tool.done` ever move `activeTool`, and a text frame
|
|
34
|
+
* moving status to `responding` does NOT clear a lingering tool name).
|
|
35
|
+
* - `message.assistantText` is the full folded text of the turn so far —
|
|
36
|
+
* render it as-is on every event; there is no delta bookkeeping to do.
|
|
37
|
+
* - `message.agEvents` are the frame's validated AgJSON events (empty for
|
|
38
|
+
* bypass frames) — push them into your own cross-turn `Reducer` if you
|
|
39
|
+
* keep a block-preserving transcript, ignore them otherwise.
|
|
40
|
+
*/
|
|
41
|
+
export type InvokeTurnEvent = {
|
|
42
|
+
kind: "session";
|
|
43
|
+
threadId: string | null;
|
|
44
|
+
} | {
|
|
45
|
+
kind: "message";
|
|
46
|
+
status?: Extract<AgentInvokeStatus, "thinking" | "using-tool" | "responding">;
|
|
47
|
+
activeTool?: string | null;
|
|
48
|
+
assistantText: string;
|
|
49
|
+
agEvents: AgEvent[];
|
|
50
|
+
} | {
|
|
51
|
+
kind: "error";
|
|
52
|
+
message: string;
|
|
53
|
+
code: string | null;
|
|
54
|
+
} | {
|
|
55
|
+
kind: "profile-consent";
|
|
56
|
+
request: ProfileConsentRequest;
|
|
57
|
+
} | {
|
|
58
|
+
kind: "profile-link";
|
|
59
|
+
request: ProfileLinkRequest;
|
|
60
|
+
} | {
|
|
61
|
+
kind: "done";
|
|
62
|
+
stopReason: string | null;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Normalize an agent endpoint to its invoke URL (guuey#186 G3). Accepts BOTH
|
|
66
|
+
* shapes a consumer legitimately holds — a pod base (`https://host`) and the
|
|
67
|
+
* full invoke URL the deploy-controller records (`https://host/agent/invoke`)
|
|
68
|
+
* — and returns exactly one `/agent/invoke`, trailing slashes dropped. This
|
|
69
|
+
* is the single normalization `useAgentInvoke` applies to its `endpointUrl`;
|
|
70
|
+
* a host driving {@link invokeTurn} (or any raw transport) directly builds
|
|
71
|
+
* its request URL with the same call instead of re-implementing the rule.
|
|
72
|
+
*/
|
|
73
|
+
export declare function toInvokeUrl(endpointUrl: string): string;
|
|
74
|
+
/**
|
|
75
|
+
* Drive one `/agent/invoke` turn over `transport`, yielding semantic events.
|
|
76
|
+
* Pure per-turn: no React, no storage, no retry policy (the transport owns
|
|
77
|
+
* saturation retry), no reducer — see the module docblock for what belongs
|
|
78
|
+
* to the caller.
|
|
79
|
+
*
|
|
80
|
+
* The event stream is also the OBSERVATION channel (guuey#186 Gap 4): there
|
|
81
|
+
* is deliberately no `onToolResult` callback API, because filtering the
|
|
82
|
+
* generator expresses it directly — every tool result arrives as a typed
|
|
83
|
+
* `tool.done` AgEvent on a `message` event, carrying `toolCallId`,
|
|
84
|
+
* `content`, `outcome` and `structuredContent`.
|
|
85
|
+
*
|
|
86
|
+
* @example Telemetry off the fold — observe tool results without touching
|
|
87
|
+
* the transcript path:
|
|
88
|
+
* ```ts
|
|
89
|
+
* for await (const ev of invokeTurn(req, transport)) {
|
|
90
|
+
* if (ev.kind !== "message") continue;
|
|
91
|
+
* for (const agEvent of ev.agEvents) {
|
|
92
|
+
* if (agEvent.type === "tool.done") {
|
|
93
|
+
* telemetry.record(agEvent.toolCallId, agEvent.outcome ?? "ok");
|
|
94
|
+
* }
|
|
95
|
+
* }
|
|
96
|
+
* render(ev.assistantText); // the fold is untouched by the observation
|
|
97
|
+
* }
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export declare function invokeTurn(req: InvokeRequest, transport: InvokeTransport): AsyncGenerator<InvokeTurnEvent>;
|
|
101
|
+
//# sourceMappingURL=invoke-turn.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invoke-turn.d.ts","sourceRoot":"","sources":["../src/invoke-turn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AASpD,OAAO,KAAK,EACV,iBAAiB,EACjB,aAAa,EACb,eAAe,EACf,qBAAqB,EACrB,kBAAkB,EACnB,MAAM,YAAY,CAAC;AAEpB;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,eAAe,GACvB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAC5C;IACE,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,CAAC,EAAE,OAAO,CAAC,iBAAiB,EAAE,UAAU,GAAG,YAAY,GAAG,YAAY,CAAC,CAAC;IAC9E,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,OAAO,EAAE,CAAC;CACrB,GACD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GACvD;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,OAAO,EAAE,qBAAqB,CAAA;CAAE,GAC3D;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,OAAO,EAAE,kBAAkB,CAAA;CAAE,GACrD;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC;AAEhD;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAGvD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAuB,UAAU,CAC/B,GAAG,EAAE,aAAa,EAClB,SAAS,EAAE,eAAe,GACzB,cAAc,CAAC,eAAe,CAAC,CA0EjC"}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { parseConsentRequest, parseLinkRequest, parseSseEvents, reduceAssistantText, stringField, } from "./sse.js";
|
|
2
|
+
import { ingestMessageFrame } from "./blocks.js";
|
|
3
|
+
/**
|
|
4
|
+
* Normalize an agent endpoint to its invoke URL (guuey#186 G3). Accepts BOTH
|
|
5
|
+
* shapes a consumer legitimately holds — a pod base (`https://host`) and the
|
|
6
|
+
* full invoke URL the deploy-controller records (`https://host/agent/invoke`)
|
|
7
|
+
* — and returns exactly one `/agent/invoke`, trailing slashes dropped. This
|
|
8
|
+
* is the single normalization `useAgentInvoke` applies to its `endpointUrl`;
|
|
9
|
+
* a host driving {@link invokeTurn} (or any raw transport) directly builds
|
|
10
|
+
* its request URL with the same call instead of re-implementing the rule.
|
|
11
|
+
*/
|
|
12
|
+
export function toInvokeUrl(endpointUrl) {
|
|
13
|
+
const base = endpointUrl.replace(/\/+$/, "");
|
|
14
|
+
return base.endsWith("/agent/invoke") ? base : `${base}/agent/invoke`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Drive one `/agent/invoke` turn over `transport`, yielding semantic events.
|
|
18
|
+
* Pure per-turn: no React, no storage, no retry policy (the transport owns
|
|
19
|
+
* saturation retry), no reducer — see the module docblock for what belongs
|
|
20
|
+
* to the caller.
|
|
21
|
+
*
|
|
22
|
+
* The event stream is also the OBSERVATION channel (guuey#186 Gap 4): there
|
|
23
|
+
* is deliberately no `onToolResult` callback API, because filtering the
|
|
24
|
+
* generator expresses it directly — every tool result arrives as a typed
|
|
25
|
+
* `tool.done` AgEvent on a `message` event, carrying `toolCallId`,
|
|
26
|
+
* `content`, `outcome` and `structuredContent`.
|
|
27
|
+
*
|
|
28
|
+
* @example Telemetry off the fold — observe tool results without touching
|
|
29
|
+
* the transcript path:
|
|
30
|
+
* ```ts
|
|
31
|
+
* for await (const ev of invokeTurn(req, transport)) {
|
|
32
|
+
* if (ev.kind !== "message") continue;
|
|
33
|
+
* for (const agEvent of ev.agEvents) {
|
|
34
|
+
* if (agEvent.type === "tool.done") {
|
|
35
|
+
* telemetry.record(agEvent.toolCallId, agEvent.outcome ?? "ok");
|
|
36
|
+
* }
|
|
37
|
+
* }
|
|
38
|
+
* render(ev.assistantText); // the fold is untouched by the observation
|
|
39
|
+
* }
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export async function* invokeTurn(req, transport) {
|
|
43
|
+
let assistantText = "";
|
|
44
|
+
let buffer = "";
|
|
45
|
+
for await (const chunk of transport(req)) {
|
|
46
|
+
buffer += chunk;
|
|
47
|
+
const { events, rest } = parseSseEvents(buffer);
|
|
48
|
+
buffer = rest;
|
|
49
|
+
for (const ev of events) {
|
|
50
|
+
if (ev.event === "session") {
|
|
51
|
+
// The pod is awake and the turn is admitted (this frame arrives
|
|
52
|
+
// within ~1s of a warm pod; a cold scale-to-zero start is exactly
|
|
53
|
+
// the long wait before it).
|
|
54
|
+
yield { kind: "session", threadId: stringField(ev.data, "threadId") ?? null };
|
|
55
|
+
}
|
|
56
|
+
else if (ev.event === "message") {
|
|
57
|
+
// Status derivation (guuey#91) — read the frame's `type` before the
|
|
58
|
+
// text fold. Silver frames announce tools + text explicitly; bypass
|
|
59
|
+
// frames ('text' / 'assistant' SDKMessages) only ever carry
|
|
60
|
+
// assistant text, so they map to 'responding'. Unknown types
|
|
61
|
+
// deliberately imply no status change.
|
|
62
|
+
const frameType = stringField(ev.data, "type");
|
|
63
|
+
assistantText = reduceAssistantText(assistantText, ev.data);
|
|
64
|
+
// Only VALID AgEvents surface (bypass frames ingest to []) — the
|
|
65
|
+
// caller's reducer, if any, advances on these alone.
|
|
66
|
+
const agEvents = ingestMessageFrame(ev.data);
|
|
67
|
+
if (frameType === "tool.start") {
|
|
68
|
+
yield {
|
|
69
|
+
kind: "message",
|
|
70
|
+
status: "using-tool",
|
|
71
|
+
activeTool: stringField(ev.data, "name") ?? null,
|
|
72
|
+
assistantText,
|
|
73
|
+
agEvents,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
else if (frameType === "tool.done") {
|
|
77
|
+
yield { kind: "message", status: "thinking", activeTool: null, assistantText, agEvents };
|
|
78
|
+
}
|
|
79
|
+
else if (frameType === "text.start" ||
|
|
80
|
+
frameType === "text.delta" ||
|
|
81
|
+
frameType === "text" ||
|
|
82
|
+
frameType === "assistant") {
|
|
83
|
+
yield { kind: "message", status: "responding", assistantText, agEvents };
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
yield { kind: "message", assistantText, agEvents };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else if (ev.event === "error") {
|
|
90
|
+
// In-band failure frame — one of the two channels that carry the
|
|
91
|
+
// pod's wire code (the other is the pre-stream refusal thrown by the
|
|
92
|
+
// transport). A frame without a `code` yields null rather than
|
|
93
|
+
// leaving a previous turn's code standing beside a new message.
|
|
94
|
+
yield {
|
|
95
|
+
kind: "error",
|
|
96
|
+
message: stringField(ev.data, "message") ?? "agent error",
|
|
97
|
+
code: stringField(ev.data, "code") ?? null,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
else if (ev.event === "profile-consent-needed") {
|
|
101
|
+
// Cross-app profile consent ask (T6). Only a well-formed payload
|
|
102
|
+
// yields; a malformed one is dropped, leaving any prior valid
|
|
103
|
+
// request untouched (never clobbered to null).
|
|
104
|
+
const parsed = parseConsentRequest(ev.data);
|
|
105
|
+
if (parsed)
|
|
106
|
+
yield { kind: "profile-consent", request: parsed };
|
|
107
|
+
}
|
|
108
|
+
else if (ev.event === "profile-link-needed") {
|
|
109
|
+
// Cross-app profile LINK invite (linkcoh T3) for an unlinked byo
|
|
110
|
+
// caller. Same drop-if-malformed contract as consent above.
|
|
111
|
+
const parsed = parseLinkRequest(ev.data);
|
|
112
|
+
if (parsed)
|
|
113
|
+
yield { kind: "profile-link", request: parsed };
|
|
114
|
+
}
|
|
115
|
+
else if (ev.event === "done") {
|
|
116
|
+
// The stream closes after this frame; yielded so a host can read the
|
|
117
|
+
// pod's stop reason without private wire knowledge.
|
|
118
|
+
yield { kind: "done", stopReason: stringField(ev.data, "stopReason") ?? null };
|
|
119
|
+
}
|
|
120
|
+
// Any other (unknown) event falls through silently — additive wire
|
|
121
|
+
// events never disturb a consumer.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
package/dist/react.d.ts
CHANGED
|
@@ -6,6 +6,6 @@
|
|
|
6
6
|
* history reader, and the web adapters). Consumers that only need those never
|
|
7
7
|
* import React at all; consumers that render chat import the hook from here.
|
|
8
8
|
*/
|
|
9
|
-
export { useAgentInvoke, applyHistoryResult, type HistoryApplication } from "./useAgentInvoke.js";
|
|
9
|
+
export { useAgentInvoke, applyHistoryResult, type HistoryApplication, stallProbeDecision, STALL_RECOVERY_DEFAULTS, } from "./useAgentInvoke.js";
|
|
10
10
|
export type { AgEvent, AgReduceResult } from "@silverprotocol/core";
|
|
11
11
|
//# sourceMappingURL=react.d.ts.map
|
package/dist/react.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,
|
|
1
|
+
{"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,cAAc,EACd,kBAAkB,EAClB,KAAK,kBAAkB,EACvB,kBAAkB,EAClB,uBAAuB,GACxB,MAAM,qBAAqB,CAAC;AAI7B,YAAY,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC"}
|
package/dist/react.js
CHANGED
|
@@ -6,4 +6,4 @@
|
|
|
6
6
|
* history reader, and the web adapters). Consumers that only need those never
|
|
7
7
|
* import React at all; consumers that render chat import the hook from here.
|
|
8
8
|
*/
|
|
9
|
-
export { useAgentInvoke, applyHistoryResult } from "./useAgentInvoke.js";
|
|
9
|
+
export { useAgentInvoke, applyHistoryResult, stallProbeDecision, STALL_RECOVERY_DEFAULTS, } from "./useAgentInvoke.js";
|
|
@@ -57,4 +57,45 @@ export interface SaturationRetryOptions {
|
|
|
57
57
|
* replaying a token that may have expired during the wait.
|
|
58
58
|
*/
|
|
59
59
|
export declare function withSaturationRetry(transport: InvokeTransport, options?: SaturationRetryOptions): InvokeTransport;
|
|
60
|
+
/** Options for {@link withColdStartRetry}. */
|
|
61
|
+
export interface ColdStartRetryOptions {
|
|
62
|
+
/**
|
|
63
|
+
* Retries after the initial attempt (`0` disables the wrapper's behaviour
|
|
64
|
+
* entirely). Default 3 — a small, bounded budget: the point is parity with
|
|
65
|
+
* guuey's first-party embeds during the ordinary post-redeploy window, not
|
|
66
|
+
* riding out an outage. Raise it for an unattended harness that would
|
|
67
|
+
* rather wait than fail.
|
|
68
|
+
*/
|
|
69
|
+
attempts?: number;
|
|
70
|
+
/**
|
|
71
|
+
* First wait in ms; each subsequent wait doubles, capped at
|
|
72
|
+
* {@link maxDelayMs}. Default 2000 → 2s / 4s / 8s for the default budget.
|
|
73
|
+
*/
|
|
74
|
+
baseDelayMs?: number;
|
|
75
|
+
/** Ceiling on any single wait (hinted or computed), in ms. Default 10000. */
|
|
76
|
+
maxDelayMs?: number;
|
|
77
|
+
/** The wait itself — injectable so tests drive the retry without timers. */
|
|
78
|
+
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Wrap an invoke transport with a bounded retry on cold-start 503s
|
|
82
|
+
* (guuey#186 Gap 3 — parity with first-party embeds, which already carry
|
|
83
|
+
* this behaviour; SDK consumers were eating the raw 503 window instead).
|
|
84
|
+
*
|
|
85
|
+
* Matches ONLY {@link isColdStartRefusal} — an envelope-less 503 — and
|
|
86
|
+
* retries up to `attempts` times with doubling, capped backoff (honouring a
|
|
87
|
+
* `Retry-After` hint when the response carried one). Exhaustion propagates
|
|
88
|
+
* the final refusal untouched.
|
|
89
|
+
*
|
|
90
|
+
* Nothing is retried once a chunk has been yielded: a stream that dies
|
|
91
|
+
* MID-turn is never silently re-POSTed — the turn may have had side effects
|
|
92
|
+
* and the consumer already saw partial output. Same `yielded` guard as
|
|
93
|
+
* {@link withSaturationRetry}, same reasoning. An abort during a wait
|
|
94
|
+
* surfaces the refusal that caused the wait.
|
|
95
|
+
*
|
|
96
|
+
* Like the saturation wrapper, the retry is invisible to `useAgentInvoke`
|
|
97
|
+
* (the turn stays in `connecting`), and the wrapped transport is re-invoked
|
|
98
|
+
* from scratch so per-attempt identity resolution re-runs.
|
|
99
|
+
*/
|
|
100
|
+
export declare function withColdStartRetry(transport: InvokeTransport, options?: ColdStartRetryOptions): InvokeTransport;
|
|
60
101
|
//# sourceMappingURL=saturation-retry.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"saturation-retry.d.ts","sourceRoot":"","sources":["../src/saturation-retry.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"saturation-retry.d.ts","sourceRoot":"","sources":["../src/saturation-retry.ts"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EAAiB,eAAe,EAAE,MAAM,YAAY,CAAC;AAiBjE;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,SAAS,CAMhF;AA4BD,+CAA+C;AAC/C,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,eAAe,EAC1B,OAAO,GAAE,sBAA2B,GACnC,eAAe,CAqBjB;AAED,8CAA8C;AAC9C,MAAM,WAAW,qBAAqB;IACpC;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5D;AAkBD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,eAAe,EAC1B,OAAO,GAAE,qBAA0B,GAClC,eAAe,CA0BjB"}
|
package/dist/saturation-retry.js
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The invoke-refusal retry wrappers, transport-agnostic: the single
|
|
3
|
+
* `POD_SATURATED` auto-retry ({@link withSaturationRetry}) and the bounded
|
|
4
|
+
* cold-start 503 retry ({@link withColdStartRetry}).
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
* born — because the behaviour is a property of the
|
|
6
|
-
* not of `fetch`. Every host that speaks `/agent/invoke`
|
|
7
|
-
* the ones that cannot import the web adapter bundle:
|
|
8
|
-
* transport wraps its own `fetch` call with
|
|
6
|
+
* Their own module — rather than living inside `./web-adapters.ts`, where the
|
|
7
|
+
* first was born — because the behaviour is a property of the platform's
|
|
8
|
+
* refusal vocabulary, not of `fetch`. Every host that speaks `/agent/invoke`
|
|
9
|
+
* wants them, including the ones that cannot import the web adapter bundle:
|
|
10
|
+
* Portal's React-Native transport wraps its own `fetch` call with these the
|
|
9
11
|
* same way `fetchStreamTransport` wraps its browser streaming reader, so the
|
|
10
12
|
* two wear byte-identical retry semantics instead of two hand-written copies
|
|
11
13
|
* that drift.
|
|
12
14
|
*
|
|
15
|
+
* The two wrappers are DELIBERATELY distinct code paths: saturation retry is
|
|
16
|
+
* driven by the pod's structured refusal envelope (`code: POD_SATURATED`),
|
|
17
|
+
* while the cold-start retry matches only an envelope-LESS 503 — the raw
|
|
18
|
+
* infra answer (ingress with no ready pod) during the post-redeploy window,
|
|
19
|
+
* which by definition carries no wire code. They share the backoff machinery,
|
|
20
|
+
* never the predicate.
|
|
21
|
+
*
|
|
13
22
|
* This module imports only `./types.js`, `./errors.js` and `./error-codes.js`
|
|
14
23
|
* — all pure — so pulling it in costs a React-Native build nothing.
|
|
15
24
|
*/
|
|
@@ -133,3 +142,66 @@ export function withSaturationRetry(transport, options = {}) {
|
|
|
133
142
|
yield* transport(req);
|
|
134
143
|
};
|
|
135
144
|
}
|
|
145
|
+
const COLD_START_DEFAULT_ATTEMPTS = 3;
|
|
146
|
+
const COLD_START_BASE_DELAY_MS = 2_000;
|
|
147
|
+
const COLD_START_MAX_DELAY_MS = 10_000;
|
|
148
|
+
/**
|
|
149
|
+
* Is this failure the cold-start refusal shape? An envelope-less 503: the
|
|
150
|
+
* status came from infra (no ready pod behind the route — the ~30–60s window
|
|
151
|
+
* after a redeploy), so there is no wire `code`. A 503 that DOES carry a code
|
|
152
|
+
* is the pod itself refusing (`POD_SATURATED`, `DRAINING`) and belongs to
|
|
153
|
+
* {@link withSaturationRetry}'s policy — including its deliberate decision NOT
|
|
154
|
+
* to retry `DRAINING` — never to this wrapper.
|
|
155
|
+
*/
|
|
156
|
+
function isColdStartRefusal(err) {
|
|
157
|
+
return err instanceof AgentResponseError && err.status === 503 && err.code === undefined;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Wrap an invoke transport with a bounded retry on cold-start 503s
|
|
161
|
+
* (guuey#186 Gap 3 — parity with first-party embeds, which already carry
|
|
162
|
+
* this behaviour; SDK consumers were eating the raw 503 window instead).
|
|
163
|
+
*
|
|
164
|
+
* Matches ONLY {@link isColdStartRefusal} — an envelope-less 503 — and
|
|
165
|
+
* retries up to `attempts` times with doubling, capped backoff (honouring a
|
|
166
|
+
* `Retry-After` hint when the response carried one). Exhaustion propagates
|
|
167
|
+
* the final refusal untouched.
|
|
168
|
+
*
|
|
169
|
+
* Nothing is retried once a chunk has been yielded: a stream that dies
|
|
170
|
+
* MID-turn is never silently re-POSTed — the turn may have had side effects
|
|
171
|
+
* and the consumer already saw partial output. Same `yielded` guard as
|
|
172
|
+
* {@link withSaturationRetry}, same reasoning. An abort during a wait
|
|
173
|
+
* surfaces the refusal that caused the wait.
|
|
174
|
+
*
|
|
175
|
+
* Like the saturation wrapper, the retry is invisible to `useAgentInvoke`
|
|
176
|
+
* (the turn stays in `connecting`), and the wrapped transport is re-invoked
|
|
177
|
+
* from scratch so per-attempt identity resolution re-runs.
|
|
178
|
+
*/
|
|
179
|
+
export function withColdStartRetry(transport, options = {}) {
|
|
180
|
+
const attempts = options.attempts ?? COLD_START_DEFAULT_ATTEMPTS;
|
|
181
|
+
const baseDelayMs = options.baseDelayMs ?? COLD_START_BASE_DELAY_MS;
|
|
182
|
+
const maxDelayMs = options.maxDelayMs ?? COLD_START_MAX_DELAY_MS;
|
|
183
|
+
const sleep = options.sleep ?? delay;
|
|
184
|
+
return async function* retrying(req) {
|
|
185
|
+
let yielded = false;
|
|
186
|
+
for (let attempt = 0;; attempt++) {
|
|
187
|
+
try {
|
|
188
|
+
for await (const chunk of transport(req)) {
|
|
189
|
+
yielded = true;
|
|
190
|
+
yield chunk;
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
if (!isColdStartRefusal(err) || yielded || attempt >= attempts)
|
|
196
|
+
throw err;
|
|
197
|
+
const hintedMs = err.retryAfterSeconds !== undefined ? err.retryAfterSeconds * 1000 : undefined;
|
|
198
|
+
const waitMs = Math.min(hintedMs ?? baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
199
|
+
await sleep(waitMs, req.signal);
|
|
200
|
+
// Aborted mid-wait: surface the refusal that caused the wait rather
|
|
201
|
+
// than spending a request that `fetch` would reject on the signal.
|
|
202
|
+
if (req.signal.aborted)
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}
|