@intx/harness 0.2.2 → 0.4.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 +9 -5
- package/dist/credential-capability.d.ts +65 -0
- package/dist/credential-capability.js +98 -0
- package/dist/credential-providers.d.ts +56 -0
- package/dist/credential-providers.js +123 -0
- package/dist/harness.d.ts +13 -47
- package/dist/harness.js +36 -121
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/reply-drain.d.ts +123 -0
- package/dist/reply-drain.js +179 -0
- package/package.json +9 -7
package/README.md
CHANGED
|
@@ -17,15 +17,19 @@ import {
|
|
|
17
17
|
} from "@intx/agent";
|
|
18
18
|
import { noopAuditStore, permissiveAuthorize } from "@intx/agent/testing";
|
|
19
19
|
import { createHarness, defineMailTools } from "@intx/harness";
|
|
20
|
-
import { createIsogitStore } from "@intx/storage-isogit";
|
|
20
|
+
import { createIsogitStore } from "@intx/storage-isogit/node";
|
|
21
21
|
|
|
22
|
-
const mailFactory = defineMailTools(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
const mailFactory = defineMailTools(
|
|
23
|
+
() => ({
|
|
24
|
+
definitions: myMailTools.definitions,
|
|
25
|
+
run: (call, signal) => myMailTools.run(call, signal),
|
|
26
|
+
}),
|
|
27
|
+
myMailTools.definitions.map((def) => ({ name: def.name })),
|
|
28
|
+
);
|
|
26
29
|
|
|
27
30
|
const posixFactory = defineTool({
|
|
28
31
|
id: "@my-org/agent/posix",
|
|
32
|
+
definitions: myPosixTools.definitions.map((def) => ({ name: def.name })),
|
|
29
33
|
factory: () => ({
|
|
30
34
|
definitions: myPosixTools.definitions,
|
|
31
35
|
run: (call, signal) => myPosixTools.run(call, signal),
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type GrantRule } from "@intx/authz";
|
|
2
|
+
import type { CredentialCapability, CredentialMaterialSource } from "@intx/types";
|
|
3
|
+
import type { ToolCredentialDeclaration } from "@intx/types/package-json";
|
|
4
|
+
import type { CredentialProviderRegistry } from "./credential-providers.js";
|
|
5
|
+
/**
|
|
6
|
+
* A binding resolved at launch: which credential backs a declared handle, which
|
|
7
|
+
* provider shapes it, the origin it authenticates to, and how to read its
|
|
8
|
+
* current material. The material source is an indirection over a mutable cell so
|
|
9
|
+
* a rotation reaches an already-shaped handle without a rebuild.
|
|
10
|
+
*/
|
|
11
|
+
export interface ResolvedCredentialBinding {
|
|
12
|
+
/** The credential row id the handle resolved to; the `credential:{id}` the
|
|
13
|
+
* use-grant check runs against. */
|
|
14
|
+
credentialId: string;
|
|
15
|
+
/** The provider plugin key that shapes this credential's handle. */
|
|
16
|
+
providerKey: string;
|
|
17
|
+
/** The provider origin the shaped handle authenticates to. */
|
|
18
|
+
origin: string;
|
|
19
|
+
/** Reads the current secret material (rotation indirection). */
|
|
20
|
+
readCurrentMaterial: CredentialMaterialSource;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Reconcile a tool package's declared credential handles (its C5 `interchange.
|
|
24
|
+
* credentials`) against the handles a binding actually resolved for it. A
|
|
25
|
+
* declared handle with no binding is a launch-blocking misconfiguration -- the
|
|
26
|
+
* tool needs a credential the definition never bound -- so this fails the launch
|
|
27
|
+
* loudly rather than letting the gap surface as a resolve-time throw at the
|
|
28
|
+
* tool's first use. It is the throw-on-missing of `resolve`, pulled earlier to
|
|
29
|
+
* launch where the whole set is known.
|
|
30
|
+
*/
|
|
31
|
+
export declare function reconcileDeclaredCredentials(consumer: string, declared: readonly ToolCredentialDeclaration[], boundHandles: ReadonlySet<string>): void;
|
|
32
|
+
export interface CredentialCapabilityDeps {
|
|
33
|
+
/**
|
|
34
|
+
* The consumer identity of the tool package this capability serves
|
|
35
|
+
* (`tool:<package>`, from `toolConsumer`). Gate 2 checks each grant's
|
|
36
|
+
* `{ tool }` condition against this value; an empty identity fails closed.
|
|
37
|
+
*/
|
|
38
|
+
consumer: string;
|
|
39
|
+
/** Resolved bindings keyed by the handle the tool declared. */
|
|
40
|
+
bindings: ReadonlyMap<string, ResolvedCredentialBinding>;
|
|
41
|
+
/** The registry that shapes a credential into a mediated handle. */
|
|
42
|
+
providers: CredentialProviderRegistry;
|
|
43
|
+
/** The grants in effect for this deploy (the consumer's run grants). */
|
|
44
|
+
grants: GrantRule[];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A `CredentialCapability` plus a host-only `dispose`. The tool sees only
|
|
48
|
+
* `resolve`; the host runs `dispose` on teardown to release every handle shaped
|
|
49
|
+
* through this capability (an http handle holds nothing; a future key-file /
|
|
50
|
+
* socket handle would).
|
|
51
|
+
*/
|
|
52
|
+
export interface HostCredentialCapability extends CredentialCapability {
|
|
53
|
+
dispose(): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Build the consumer-gated `credentials` capability for one tool package.
|
|
57
|
+
*
|
|
58
|
+
* `resolve(handle)` fails closed at every step: an unbound handle throws; a
|
|
59
|
+
* handle the consumer is not authorized to use throws (Gate 2 -- the same
|
|
60
|
+
* `authorizeAction` the model-source path uses, here supplied the credential-use
|
|
61
|
+
* condition registry and this consumer). Only an authorized handle is shaped,
|
|
62
|
+
* once, and memoized so repeated resolves return the same instance and there is
|
|
63
|
+
* a single thing to dispose.
|
|
64
|
+
*/
|
|
65
|
+
export declare function createCredentialCapability(deps: CredentialCapabilityDeps): HostCredentialCapability;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The consumer-gated `credentials` capability: the sub-registry a tool queries
|
|
2
|
+
// by its declared handle to obtain a mediated credential. It is the runtime
|
|
3
|
+
// gate that enforces the `{ tool }` condition on a materialized
|
|
4
|
+
// `credential:{id}` / `use` grant -- the check the launch-time grant
|
|
5
|
+
// materialization sets up but does not itself evaluate.
|
|
6
|
+
//
|
|
7
|
+
// The gate lives here, at the point of use, and fails closed: a handle resolves
|
|
8
|
+
// only when the calling consumer holds `credential:{id}` / `use` with the
|
|
9
|
+
// grant's `{ tool }` condition matching this consumer. The shaping of the
|
|
10
|
+
// handle is delegated to the provider registry; the material is read fresh per
|
|
11
|
+
// use (rotation indirection) from the source the binding carries.
|
|
12
|
+
import { authorizeAction, CREDENTIAL_USE_CONDITIONS, } from "@intx/authz";
|
|
13
|
+
/**
|
|
14
|
+
* Reconcile a tool package's declared credential handles (its C5 `interchange.
|
|
15
|
+
* credentials`) against the handles a binding actually resolved for it. A
|
|
16
|
+
* declared handle with no binding is a launch-blocking misconfiguration -- the
|
|
17
|
+
* tool needs a credential the definition never bound -- so this fails the launch
|
|
18
|
+
* loudly rather than letting the gap surface as a resolve-time throw at the
|
|
19
|
+
* tool's first use. It is the throw-on-missing of `resolve`, pulled earlier to
|
|
20
|
+
* launch where the whole set is known.
|
|
21
|
+
*/
|
|
22
|
+
export function reconcileDeclaredCredentials(consumer, declared, boundHandles) {
|
|
23
|
+
const missing = declared
|
|
24
|
+
.map((declaration) => declaration.handle)
|
|
25
|
+
.filter((handle) => !boundHandles.has(handle));
|
|
26
|
+
if (missing.length > 0) {
|
|
27
|
+
throw new Error(`consumer ${consumer} declares credential handle(s) that no binding resolves: ${missing.join(", ")}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Build the consumer-gated `credentials` capability for one tool package.
|
|
32
|
+
*
|
|
33
|
+
* `resolve(handle)` fails closed at every step: an unbound handle throws; a
|
|
34
|
+
* handle the consumer is not authorized to use throws (Gate 2 -- the same
|
|
35
|
+
* `authorizeAction` the model-source path uses, here supplied the credential-use
|
|
36
|
+
* condition registry and this consumer). Only an authorized handle is shaped,
|
|
37
|
+
* once, and memoized so repeated resolves return the same instance and there is
|
|
38
|
+
* a single thing to dispose.
|
|
39
|
+
*/
|
|
40
|
+
export function createCredentialCapability(deps) {
|
|
41
|
+
// Memoize the in-flight PROMISE, not the resolved handle, so two concurrent
|
|
42
|
+
// resolves of the same handle share one gate+shape and yield one instance
|
|
43
|
+
// (caching the value would let both miss the memo and shape twice, orphaning
|
|
44
|
+
// a handle). A deterministic failure -- unbound handle, denied gate, unknown
|
|
45
|
+
// provider -- caches too; it stays failed for this deploy, which is correct
|
|
46
|
+
// since grants do not change mid-deploy.
|
|
47
|
+
const shaped = new Map();
|
|
48
|
+
function shapeHandle(handle) {
|
|
49
|
+
return (async () => {
|
|
50
|
+
const binding = deps.bindings.get(handle);
|
|
51
|
+
if (binding === undefined) {
|
|
52
|
+
throw new Error(`no credential is bound to handle "${handle}" for consumer ${deps.consumer}`);
|
|
53
|
+
}
|
|
54
|
+
// Gate 2: fail closed unless the consumer holds credential:{id} / use with
|
|
55
|
+
// the grant's { tool } condition matching this consumer.
|
|
56
|
+
const decision = await authorizeAction(deps.grants, `credential:${binding.credentialId}`, "use", { registry: CREDENTIAL_USE_CONDITIONS, consumer: deps.consumer });
|
|
57
|
+
if (!decision.ok) {
|
|
58
|
+
throw new Error(`consumer ${deps.consumer} is not authorized to use credential ${binding.credentialId} (${decision.reason})`);
|
|
59
|
+
}
|
|
60
|
+
const provider = deps.providers.resolve(binding.providerKey);
|
|
61
|
+
return provider.shape({
|
|
62
|
+
origin: binding.origin,
|
|
63
|
+
readCurrentMaterial: binding.readCurrentMaterial,
|
|
64
|
+
});
|
|
65
|
+
})();
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
resolve(handle) {
|
|
69
|
+
const existing = shaped.get(handle);
|
|
70
|
+
if (existing !== undefined)
|
|
71
|
+
return existing;
|
|
72
|
+
const pending = shapeHandle(handle);
|
|
73
|
+
shaped.set(handle, pending);
|
|
74
|
+
return pending;
|
|
75
|
+
},
|
|
76
|
+
async dispose() {
|
|
77
|
+
// Dispose EVERY successfully-shaped handle even if one throws -- a single
|
|
78
|
+
// bad handle must not strand the rest -- then surface any failures loudly
|
|
79
|
+
// rather than swallowing them.
|
|
80
|
+
const settled = await Promise.allSettled([...shaped.values()]);
|
|
81
|
+
shaped.clear();
|
|
82
|
+
const errors = [];
|
|
83
|
+
for (const result of settled) {
|
|
84
|
+
if (result.status !== "fulfilled")
|
|
85
|
+
continue;
|
|
86
|
+
try {
|
|
87
|
+
await result.value.dispose();
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
errors.push(error);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (errors.length > 0) {
|
|
94
|
+
throw new AggregateError(errors, "one or more credential handles failed to dispose");
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { CredentialProvider } from "@intx/types";
|
|
2
|
+
/** Resolves a provider identifier to the plugin that shapes its handles. */
|
|
3
|
+
export interface CredentialProviderRegistry {
|
|
4
|
+
has(key: string): boolean;
|
|
5
|
+
resolve(key: string): CredentialProvider;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Build a registry from a list of providers. The list is copied into a private
|
|
9
|
+
* `Map`, so callers cannot mutate the set after construction and lookups never
|
|
10
|
+
* reach `Object.prototype`. A duplicate key is a wiring error and throws at
|
|
11
|
+
* construction rather than silently shadowing.
|
|
12
|
+
*/
|
|
13
|
+
export declare function createCredentialProviderRegistry(providers: readonly CredentialProvider[]): CredentialProviderRegistry;
|
|
14
|
+
/**
|
|
15
|
+
* The minimal call signature the shaped handle needs from `fetch`. The global
|
|
16
|
+
* `fetch` satisfies it; a test stub can too, without implementing the extra
|
|
17
|
+
* members (`preconnect`) the full `fetch` type carries.
|
|
18
|
+
*/
|
|
19
|
+
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
20
|
+
/** Options for the built-in HTTP provider. */
|
|
21
|
+
export interface HttpCredentialProviderOptions {
|
|
22
|
+
/**
|
|
23
|
+
* The `fetch` the shaped handle delegates to once the request is
|
|
24
|
+
* origin-checked and the auth header is injected. Defaults to the global
|
|
25
|
+
* `fetch`; injectable so origin-pinning can be exercised without a network.
|
|
26
|
+
*/
|
|
27
|
+
fetch?: FetchLike;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The built-in HTTP credential provider. It shapes an `HttpMediatedCredential`:
|
|
31
|
+
* an authed `fetch` pinned to the credential's provider origin, injecting the
|
|
32
|
+
* current secret as a bearer token per request. The material is read fresh on
|
|
33
|
+
* every call, so a rotation that updates the underlying cell is picked up
|
|
34
|
+
* without rebuilding the handle.
|
|
35
|
+
*
|
|
36
|
+
* Origin pinning is load-bearing security: the handle authenticates only the
|
|
37
|
+
* initial, origin-checked request and never follows redirects. A request whose
|
|
38
|
+
* resolved origin is not the pinned one is refused, and a server 3xx is
|
|
39
|
+
* returned to the caller unfollowed (`redirect: "manual"`), so the bearer is
|
|
40
|
+
* never sent to any origin but the pinned one. Transparent redirect-following
|
|
41
|
+
* is intentionally not provided: a tool re-issues a same-origin redirect target
|
|
42
|
+
* through the handle (a cross-origin one is refused). This keeps token safety
|
|
43
|
+
* in the handle rather than resting on the injected `fetch`'s redirect
|
|
44
|
+
* behavior.
|
|
45
|
+
*
|
|
46
|
+
* Bearer is the only auth scheme today; providers that authenticate differently
|
|
47
|
+
* (a `token` scheme, an `x-api-key` header) are separate plugins, not a branch
|
|
48
|
+
* here.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createHttpCredentialProvider(opts?: HttpCredentialProviderOptions): CredentialProvider;
|
|
51
|
+
/**
|
|
52
|
+
* The built-in credential providers every host registers. A single `http`
|
|
53
|
+
* provider today; a host composes additional providers by extending the list
|
|
54
|
+
* passed to `createCredentialProviderRegistry`.
|
|
55
|
+
*/
|
|
56
|
+
export declare function builtinCredentialProviders(): CredentialProvider[];
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Credential provider plugins: the seam that shapes a resolved provider-backed
|
|
2
|
+
// credential into a mediated handle a consumer can use. A provider owns HOW the
|
|
3
|
+
// handle authenticates (an authed `fetch`, a future key-file + socket); it is
|
|
4
|
+
// given a material source and never acquires material or decides authorization
|
|
5
|
+
// -- both happen upstream, at the delivery boundary, before a provider is
|
|
6
|
+
// consulted.
|
|
7
|
+
//
|
|
8
|
+
// The registry mirrors @intx/inference's AdapterRegistry: a Map-backed lookup
|
|
9
|
+
// keyed by provider identifier, prototype-pollution-safe (a Map never consults
|
|
10
|
+
// Object.prototype, so an untrusted key like "toString" resolves to the loud
|
|
11
|
+
// unknown-provider error rather than an inherited member), throw-on-missing.
|
|
12
|
+
/**
|
|
13
|
+
* Build a registry from a list of providers. The list is copied into a private
|
|
14
|
+
* `Map`, so callers cannot mutate the set after construction and lookups never
|
|
15
|
+
* reach `Object.prototype`. A duplicate key is a wiring error and throws at
|
|
16
|
+
* construction rather than silently shadowing.
|
|
17
|
+
*/
|
|
18
|
+
export function createCredentialProviderRegistry(providers) {
|
|
19
|
+
const byKey = new Map();
|
|
20
|
+
for (const provider of providers) {
|
|
21
|
+
if (byKey.has(provider.key)) {
|
|
22
|
+
throw new Error(`Duplicate credential provider key: ${provider.key}`);
|
|
23
|
+
}
|
|
24
|
+
byKey.set(provider.key, provider);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
has(key) {
|
|
28
|
+
return byKey.has(key);
|
|
29
|
+
},
|
|
30
|
+
resolve(key) {
|
|
31
|
+
const provider = byKey.get(key);
|
|
32
|
+
if (provider === undefined) {
|
|
33
|
+
throw new Error(`Unknown credential provider: ${key}`);
|
|
34
|
+
}
|
|
35
|
+
return provider;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The built-in HTTP credential provider. It shapes an `HttpMediatedCredential`:
|
|
41
|
+
* an authed `fetch` pinned to the credential's provider origin, injecting the
|
|
42
|
+
* current secret as a bearer token per request. The material is read fresh on
|
|
43
|
+
* every call, so a rotation that updates the underlying cell is picked up
|
|
44
|
+
* without rebuilding the handle.
|
|
45
|
+
*
|
|
46
|
+
* Origin pinning is load-bearing security: the handle authenticates only the
|
|
47
|
+
* initial, origin-checked request and never follows redirects. A request whose
|
|
48
|
+
* resolved origin is not the pinned one is refused, and a server 3xx is
|
|
49
|
+
* returned to the caller unfollowed (`redirect: "manual"`), so the bearer is
|
|
50
|
+
* never sent to any origin but the pinned one. Transparent redirect-following
|
|
51
|
+
* is intentionally not provided: a tool re-issues a same-origin redirect target
|
|
52
|
+
* through the handle (a cross-origin one is refused). This keeps token safety
|
|
53
|
+
* in the handle rather than resting on the injected `fetch`'s redirect
|
|
54
|
+
* behavior.
|
|
55
|
+
*
|
|
56
|
+
* Bearer is the only auth scheme today; providers that authenticate differently
|
|
57
|
+
* (a `token` scheme, an `x-api-key` header) are separate plugins, not a branch
|
|
58
|
+
* here.
|
|
59
|
+
*/
|
|
60
|
+
export function createHttpCredentialProvider(opts) {
|
|
61
|
+
const fetchImpl = opts?.fetch ?? globalThis.fetch;
|
|
62
|
+
return {
|
|
63
|
+
key: "http",
|
|
64
|
+
shape(context) {
|
|
65
|
+
const pinnedOrigin = new URL(context.origin).origin;
|
|
66
|
+
return {
|
|
67
|
+
kind: "http",
|
|
68
|
+
async fetch(input, init) {
|
|
69
|
+
const target = resolveTargetUrl(input, pinnedOrigin);
|
|
70
|
+
if (target.origin !== pinnedOrigin) {
|
|
71
|
+
throw new Error(`http credential is pinned to ${pinnedOrigin}; refusing cross-origin request to ${target.origin}`);
|
|
72
|
+
}
|
|
73
|
+
// Read the secret fresh on every call so a rotation of the underlying
|
|
74
|
+
// material cell reaches this handle without a rebuild.
|
|
75
|
+
const { secret } = context.readCurrentMaterial();
|
|
76
|
+
// redirect:"manual" is dictated by the handle, never inherited from
|
|
77
|
+
// caller input. The origin check guards only the INITIAL url, so
|
|
78
|
+
// following a server 3xx to a foreign origin would carry the bearer
|
|
79
|
+
// off the pinned host. Instead the 3xx is returned to the caller
|
|
80
|
+
// unfollowed: a same-origin target is re-issued through the handle
|
|
81
|
+
// (which re-pins and re-auths); a cross-origin one is refused above.
|
|
82
|
+
if (input instanceof Request) {
|
|
83
|
+
// Re-issue the caller's request (method, body preserved) with the
|
|
84
|
+
// auth header added and the redirect mode forced; its url was
|
|
85
|
+
// origin-checked above.
|
|
86
|
+
const headers = new Headers(input.headers);
|
|
87
|
+
headers.set("authorization", `Bearer ${secret}`);
|
|
88
|
+
return fetchImpl(new Request(input, { headers, redirect: "manual" }));
|
|
89
|
+
}
|
|
90
|
+
const headers = new Headers(init?.headers);
|
|
91
|
+
headers.set("authorization", `Bearer ${secret}`);
|
|
92
|
+
return fetchImpl(target, { ...init, headers, redirect: "manual" });
|
|
93
|
+
},
|
|
94
|
+
dispose() {
|
|
95
|
+
// An http handle allocates no resources; nothing to release.
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The built-in credential providers every host registers. A single `http`
|
|
103
|
+
* provider today; a host composes additional providers by extending the list
|
|
104
|
+
* passed to `createCredentialProviderRegistry`.
|
|
105
|
+
*/
|
|
106
|
+
export function builtinCredentialProviders() {
|
|
107
|
+
return [createHttpCredentialProvider()];
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the URL a request targets. A relative string resolves against the
|
|
111
|
+
* pinned origin (so a tool can call `/repos`); an absolute string or URL keeps
|
|
112
|
+
* its own origin (and is refused by the caller if it differs); a `Request`
|
|
113
|
+
* carries an absolute URL already.
|
|
114
|
+
*/
|
|
115
|
+
function resolveTargetUrl(input, pinnedOrigin) {
|
|
116
|
+
if (typeof input === "string") {
|
|
117
|
+
return new URL(input, pinnedOrigin);
|
|
118
|
+
}
|
|
119
|
+
if (input instanceof URL) {
|
|
120
|
+
return input;
|
|
121
|
+
}
|
|
122
|
+
return new URL(input.url);
|
|
123
|
+
}
|
package/dist/harness.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Agent, type AgentDefinition, type AnnotatedToolFactory, type BaseEnv, type ToolBundle } from "@intx/agent";
|
|
1
|
+
import { type Agent, type AgentDefinition, type AnnotatedToolFactory, type BaseEnv, type ToolBundle, type ToolDeclaration } from "@intx/agent";
|
|
2
2
|
import type { BlobReader, ConnectorThreadState, ContextStore, InboundMessage, InferenceSource, MessageTransport } from "@intx/types/runtime";
|
|
3
3
|
import { createConnectorRouter } from "./connector-router.js";
|
|
4
4
|
/**
|
|
@@ -64,45 +64,6 @@ export interface Harness {
|
|
|
64
64
|
* implementation to use.
|
|
65
65
|
*/
|
|
66
66
|
export type MailToolWrapper = (transport: MessageTransport) => Omit<ToolBundle, "dispose">;
|
|
67
|
-
/**
|
|
68
|
-
* Invoke the caller-supplied `onReplySendFailed` callback and absorb any
|
|
69
|
-
* failure it raises. Extracted from the reply drain so the await-the-
|
|
70
|
-
* callback contract is testable in isolation: a bare invocation would
|
|
71
|
-
* compile (TypeScript admits `async () => void` as satisfying a `void`-
|
|
72
|
-
* returning signature) but would let an async callback's rejection
|
|
73
|
-
* escape as an unhandled promise rejection. Awaiting protects against
|
|
74
|
-
* that; the helper exists so the protection is asserted by a test
|
|
75
|
-
* rather than implied by inspection of the drain.
|
|
76
|
-
*
|
|
77
|
-
* Exported only for the regression test in this package; no external
|
|
78
|
-
* consumer should call it.
|
|
79
|
-
*
|
|
80
|
-
* The export-and-mark-internal shape is the codebase's convention
|
|
81
|
-
* for helpers that exist to make a production-code contract
|
|
82
|
-
* testable in isolation. A separate `@intx/harness/testing`
|
|
83
|
-
* entry-point was considered and rejected: the helper is one
|
|
84
|
-
* try/catch wrapper around the production callback, tightly
|
|
85
|
-
* coupled to the `MailEnv` callback type defined adjacent to it.
|
|
86
|
-
* Moving it would either duplicate the production code in a test
|
|
87
|
-
* module (defeating the point) or require a parallel entry-point
|
|
88
|
-
* whose only export is a single function -- bundler ceremony for a
|
|
89
|
-
* boundary TypeScript cannot enforce anyway, since deep imports
|
|
90
|
-
* (`@intx/harness/src/harness`) reach the same module regardless
|
|
91
|
-
* of what `index.ts` re-exports. The docstring convention is
|
|
92
|
-
* load-bearing here: the marker is the contract.
|
|
93
|
-
*
|
|
94
|
-
* `invokeReplyDrainTerminated` (below) follows the same shape for
|
|
95
|
-
* the same reason.
|
|
96
|
-
*/
|
|
97
|
-
export declare function invokeReplySendFailed(callback: NonNullable<MailEnv["onReplySendFailed"]>, cause: unknown): Promise<void>;
|
|
98
|
-
/**
|
|
99
|
-
* Invoke the caller-supplied `onReplyDrainTerminated` callback and
|
|
100
|
-
* absorb any failure it raises. Mirrors `invokeReplySendFailed`:
|
|
101
|
-
* extracted from the reply drain so the await-the-callback contract
|
|
102
|
-
* is testable in isolation, exported only for the regression test in
|
|
103
|
-
* this package.
|
|
104
|
-
*/
|
|
105
|
-
export declare function invokeReplyDrainTerminated(callback: NonNullable<MailEnv["onReplyDrainTerminated"]>, cause: unknown): Promise<void>;
|
|
106
67
|
/**
|
|
107
68
|
* Build the `load` / `writeMetadata` overrides the harness layers onto
|
|
108
69
|
* `env.storage`. Extracted from `createHarness` so the dirty-bit gating
|
|
@@ -118,12 +79,11 @@ export declare function invokeReplyDrainTerminated(callback: NonNullable<MailEnv
|
|
|
118
79
|
* pre-commit disk value.
|
|
119
80
|
*
|
|
120
81
|
* Exported for the regression test in this package; no external
|
|
121
|
-
* consumer should call it.
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* enforce. The docstring "internal" marker is the contract.
|
|
82
|
+
* consumer should call it. The helper is tightly coupled to the
|
|
83
|
+
* dirty-bit gating semantics that live in this module, and a separate
|
|
84
|
+
* testing entry-point would buy bundler ceremony for a boundary
|
|
85
|
+
* TypeScript cannot enforce. The docstring "internal" marker is the
|
|
86
|
+
* contract.
|
|
127
87
|
*/
|
|
128
88
|
export declare function createWrappedStorageOverrides(baseStorage: ContextStore, connectorRouter: ReturnType<typeof createConnectorRouter>, isInMemoryStateAuthoritative: () => boolean): Pick<ContextStore, "load" | "writeMetadata">;
|
|
129
89
|
/**
|
|
@@ -167,8 +127,14 @@ export declare function createWrappedStorageOverrides(baseStorage: ContextStore,
|
|
|
167
127
|
* the "what does the harness need vs. what does the tool runner
|
|
168
128
|
* need" partition onto the caller, which is exactly the partition
|
|
169
129
|
* this helper exists to hide.
|
|
130
|
+
*
|
|
131
|
+
* `definitions` is the static declaration `defineTool` requires: the
|
|
132
|
+
* tool names this factory contributes, enumerable without invoking the
|
|
133
|
+
* wrapper. The caller supplies it because the wrapper binds `transport`
|
|
134
|
+
* from env and cannot run at declaration time; the caller already holds
|
|
135
|
+
* the mail-tool runner whose `definitions` name the same tools.
|
|
170
136
|
*/
|
|
171
|
-
export declare function defineMailTools(wrapper: MailToolWrapper): AnnotatedToolFactory<MailEnv>;
|
|
137
|
+
export declare function defineMailTools(wrapper: MailToolWrapper, definitions: readonly ToolDeclaration[]): AnnotatedToolFactory<MailEnv>;
|
|
172
138
|
/**
|
|
173
139
|
* Construct a composition-layer agent: the underlying agent wrapped
|
|
174
140
|
* with connector-state-aware storage, transport subscription, INBOX
|
package/dist/harness.js
CHANGED
|
@@ -14,60 +14,8 @@
|
|
|
14
14
|
import { createAgent, defineTool, } from "@intx/agent";
|
|
15
15
|
import { getLogger } from "@intx/log";
|
|
16
16
|
import { createConnectorRouter } from "./connector-router.js";
|
|
17
|
+
import { driveConnectorReplies } from "./reply-drain.js";
|
|
17
18
|
const logger = getLogger(["interchange", "harness"]);
|
|
18
|
-
/**
|
|
19
|
-
* Invoke the caller-supplied `onReplySendFailed` callback and absorb any
|
|
20
|
-
* failure it raises. Extracted from the reply drain so the await-the-
|
|
21
|
-
* callback contract is testable in isolation: a bare invocation would
|
|
22
|
-
* compile (TypeScript admits `async () => void` as satisfying a `void`-
|
|
23
|
-
* returning signature) but would let an async callback's rejection
|
|
24
|
-
* escape as an unhandled promise rejection. Awaiting protects against
|
|
25
|
-
* that; the helper exists so the protection is asserted by a test
|
|
26
|
-
* rather than implied by inspection of the drain.
|
|
27
|
-
*
|
|
28
|
-
* Exported only for the regression test in this package; no external
|
|
29
|
-
* consumer should call it.
|
|
30
|
-
*
|
|
31
|
-
* The export-and-mark-internal shape is the codebase's convention
|
|
32
|
-
* for helpers that exist to make a production-code contract
|
|
33
|
-
* testable in isolation. A separate `@intx/harness/testing`
|
|
34
|
-
* entry-point was considered and rejected: the helper is one
|
|
35
|
-
* try/catch wrapper around the production callback, tightly
|
|
36
|
-
* coupled to the `MailEnv` callback type defined adjacent to it.
|
|
37
|
-
* Moving it would either duplicate the production code in a test
|
|
38
|
-
* module (defeating the point) or require a parallel entry-point
|
|
39
|
-
* whose only export is a single function -- bundler ceremony for a
|
|
40
|
-
* boundary TypeScript cannot enforce anyway, since deep imports
|
|
41
|
-
* (`@intx/harness/src/harness`) reach the same module regardless
|
|
42
|
-
* of what `index.ts` re-exports. The docstring convention is
|
|
43
|
-
* load-bearing here: the marker is the contract.
|
|
44
|
-
*
|
|
45
|
-
* `invokeReplyDrainTerminated` (below) follows the same shape for
|
|
46
|
-
* the same reason.
|
|
47
|
-
*/
|
|
48
|
-
export async function invokeReplySendFailed(callback, cause) {
|
|
49
|
-
try {
|
|
50
|
-
await callback(cause);
|
|
51
|
-
}
|
|
52
|
-
catch (callbackError) {
|
|
53
|
-
logger.error `onReplySendFailed callback threw: ${callbackError}`;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
|
-
* Invoke the caller-supplied `onReplyDrainTerminated` callback and
|
|
58
|
-
* absorb any failure it raises. Mirrors `invokeReplySendFailed`:
|
|
59
|
-
* extracted from the reply drain so the await-the-callback contract
|
|
60
|
-
* is testable in isolation, exported only for the regression test in
|
|
61
|
-
* this package.
|
|
62
|
-
*/
|
|
63
|
-
export async function invokeReplyDrainTerminated(callback, cause) {
|
|
64
|
-
try {
|
|
65
|
-
await callback(cause);
|
|
66
|
-
}
|
|
67
|
-
catch (callbackError) {
|
|
68
|
-
logger.error `onReplyDrainTerminated callback threw: ${callbackError}`;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
19
|
/**
|
|
72
20
|
* Build the `load` / `writeMetadata` overrides the harness layers onto
|
|
73
21
|
* `env.storage`. Extracted from `createHarness` so the dirty-bit gating
|
|
@@ -83,12 +31,11 @@ export async function invokeReplyDrainTerminated(callback, cause) {
|
|
|
83
31
|
* pre-commit disk value.
|
|
84
32
|
*
|
|
85
33
|
* Exported for the regression test in this package; no external
|
|
86
|
-
* consumer should call it.
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* enforce. The docstring "internal" marker is the contract.
|
|
34
|
+
* consumer should call it. The helper is tightly coupled to the
|
|
35
|
+
* dirty-bit gating semantics that live in this module, and a separate
|
|
36
|
+
* testing entry-point would buy bundler ceremony for a boundary
|
|
37
|
+
* TypeScript cannot enforce. The docstring "internal" marker is the
|
|
38
|
+
* contract.
|
|
92
39
|
*/
|
|
93
40
|
export function createWrappedStorageOverrides(baseStorage, connectorRouter, isInMemoryStateAuthoritative) {
|
|
94
41
|
return {
|
|
@@ -146,11 +93,18 @@ export function createWrappedStorageOverrides(baseStorage, connectorRouter, isIn
|
|
|
146
93
|
* the "what does the harness need vs. what does the tool runner
|
|
147
94
|
* need" partition onto the caller, which is exactly the partition
|
|
148
95
|
* this helper exists to hide.
|
|
96
|
+
*
|
|
97
|
+
* `definitions` is the static declaration `defineTool` requires: the
|
|
98
|
+
* tool names this factory contributes, enumerable without invoking the
|
|
99
|
+
* wrapper. The caller supplies it because the wrapper binds `transport`
|
|
100
|
+
* from env and cannot run at declaration time; the caller already holds
|
|
101
|
+
* the mail-tool runner whose `definitions` name the same tools.
|
|
149
102
|
*/
|
|
150
|
-
export function defineMailTools(wrapper) {
|
|
103
|
+
export function defineMailTools(wrapper, definitions) {
|
|
151
104
|
return defineTool({
|
|
152
105
|
id: "@intx/harness/mail",
|
|
153
106
|
requires: ["transport", "address"],
|
|
107
|
+
definitions,
|
|
154
108
|
factory: (env) => {
|
|
155
109
|
const bundle = wrapper(env.transport);
|
|
156
110
|
return {
|
|
@@ -230,8 +184,8 @@ export async function createHarness(def, env) {
|
|
|
230
184
|
const agentEnv = { ...env, storage: wrappedStorage };
|
|
231
185
|
const agent = await createAgent(def, agentEnv);
|
|
232
186
|
// From here through the final `return`, the agent is constructed
|
|
233
|
-
// and the workdir lock is held. Anything that throws -- the
|
|
234
|
-
//
|
|
187
|
+
// and the workdir lock is held. Anything that throws -- the
|
|
188
|
+
// `driveConnectorReplies` setup, `transport.watch()`,
|
|
235
189
|
// anything in the watch callback's synchronous registration -- has
|
|
236
190
|
// to release the lock by closing the agent before re-raising; the
|
|
237
191
|
// caller never sees the agent and cannot do it themselves.
|
|
@@ -245,63 +199,24 @@ export async function createHarness(def, env) {
|
|
|
245
199
|
// else flows past unobserved. Other consumers can subscribe to the
|
|
246
200
|
// exposed `stream()` method to see the same events.
|
|
247
201
|
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
...parts,
|
|
267
|
-
content: replyContent,
|
|
268
|
-
type: "conversation.message",
|
|
269
|
-
});
|
|
270
|
-
connectorRouter.onReplySent(receipt);
|
|
271
|
-
}
|
|
272
|
-
catch (cause) {
|
|
273
|
-
// The reply is dropped and the router state stays at
|
|
274
|
-
// its pre-send value. Surface the loss to the caller's
|
|
275
|
-
// optional onReplySendFailed callback in addition to
|
|
276
|
-
// the operator-facing log so programmatic consumers
|
|
277
|
-
// (retries, alerting) can observe what logger.error
|
|
278
|
-
// alone hides.
|
|
279
|
-
logger.error `Failed to send connector reply: ${cause}`;
|
|
280
|
-
if (env.onReplySendFailed !== undefined) {
|
|
281
|
-
await invokeReplySendFailed(env.onReplySendFailed, cause);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
});
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
// Drain any pending reply before the loop exits so close() sees
|
|
288
|
-
// a settled state.
|
|
289
|
-
await replyChain;
|
|
290
|
-
}
|
|
291
|
-
catch (cause) {
|
|
292
|
-
// The agent's stream throws on backpressure violations; log and
|
|
293
|
-
// exit the drain. The reply path stops working but the rest of
|
|
294
|
-
// the harness keeps running until close() tears it down.
|
|
295
|
-
// Surface the loss to the caller's optional
|
|
296
|
-
// `onReplyDrainTerminated` callback so programmatic consumers
|
|
297
|
-
// (alerting, watchdogs) can observe what `logger.warn` alone
|
|
298
|
-
// hides.
|
|
299
|
-
logger.warn `Reply-drain stream terminated: ${cause}`;
|
|
300
|
-
if (env.onReplyDrainTerminated !== undefined) {
|
|
301
|
-
await invokeReplyDrainTerminated(env.onReplyDrainTerminated, cause);
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
})();
|
|
202
|
+
// The shared `driveConnectorReplies` helper owns the loop: reply
|
|
203
|
+
// serialization (a second reply waits for the first's receipt to
|
|
204
|
+
// advance the router before composing its own), per-reply failure
|
|
205
|
+
// surfacing to `onReplySendFailed`, and abnormal-termination
|
|
206
|
+
// surfacing to `onReplyDrainTerminated`. The warm workflow-host
|
|
207
|
+
// path drives replies through the same helper.
|
|
208
|
+
const replyDrain = driveConnectorReplies({
|
|
209
|
+
stream: agent.stream(),
|
|
210
|
+
composeReply: () => connectorRouter.composeReply(),
|
|
211
|
+
send: (message) => transport.send(message),
|
|
212
|
+
onReplySent: (receipt) => connectorRouter.onReplySent(receipt),
|
|
213
|
+
...(env.onReplySendFailed !== undefined
|
|
214
|
+
? { onSendFailed: env.onReplySendFailed }
|
|
215
|
+
: {}),
|
|
216
|
+
...(env.onReplyDrainTerminated !== undefined
|
|
217
|
+
? { onTerminated: env.onReplyDrainTerminated }
|
|
218
|
+
: {}),
|
|
219
|
+
});
|
|
305
220
|
// Delete a message from the INBOX after it has been delivered to the
|
|
306
221
|
// reactor.
|
|
307
222
|
//
|
|
@@ -405,12 +320,12 @@ export async function createHarness(def, env) {
|
|
|
405
320
|
return;
|
|
406
321
|
stopped = true;
|
|
407
322
|
unsubscribe();
|
|
408
|
-
|
|
323
|
+
replyDrain.stop();
|
|
409
324
|
await agent.close();
|
|
410
325
|
// The reply-drain loop exits once the underlying stream closes
|
|
411
326
|
// (close() above terminates streamConsumers). Awaiting here makes
|
|
412
327
|
// close idempotent and lets callers rely on a settled state.
|
|
413
|
-
await
|
|
328
|
+
await replyDrain.done;
|
|
414
329
|
}
|
|
415
330
|
const harness = {
|
|
416
331
|
close,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export { createHarness, defineMailTools, type Harness, type MailEnv, type MailToolWrapper, } from "./harness.js";
|
|
2
2
|
export { createHarnessRuntimeCapabilities } from "./runtime-capabilities.js";
|
|
3
3
|
export type { HarnessRuntimeCapabilitiesOptions } from "./runtime-capabilities.js";
|
|
4
|
+
export { createCredentialProviderRegistry, createHttpCredentialProvider, builtinCredentialProviders, } from "./credential-providers.js";
|
|
5
|
+
export type { CredentialProviderRegistry, FetchLike, HttpCredentialProviderOptions, } from "./credential-providers.js";
|
|
6
|
+
export { createCredentialCapability, reconcileDeclaredCredentials, } from "./credential-capability.js";
|
|
7
|
+
export type { CredentialCapabilityDeps, HostCredentialCapability, ResolvedCredentialBinding, } from "./credential-capability.js";
|
|
4
8
|
export { createConnectorRouter, NoActiveConnectorThreadError, } from "./connector-router.js";
|
|
5
9
|
export type { ConnectorRouter, ConnectorReplyParts, ConnectorRouterOptions, RouteDecision, } from "./connector-router.js";
|
|
10
|
+
export { driveConnectorReplies } from "./reply-drain.js";
|
|
11
|
+
export type { AgentEventStream, ConnectorReplyDrain, ConnectorReplyDrainOpts, ReplySettlement, } from "./reply-drain.js";
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
export { createHarness, defineMailTools, } from "./harness.js";
|
|
2
2
|
export { createHarnessRuntimeCapabilities } from "./runtime-capabilities.js";
|
|
3
|
+
export { createCredentialProviderRegistry, createHttpCredentialProvider, builtinCredentialProviders, } from "./credential-providers.js";
|
|
4
|
+
export { createCredentialCapability, reconcileDeclaredCredentials, } from "./credential-capability.js";
|
|
3
5
|
export { createConnectorRouter, NoActiveConnectorThreadError, } from "./connector-router.js";
|
|
6
|
+
export { driveConnectorReplies } from "./reply-drain.js";
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { Agent } from "@intx/agent";
|
|
2
|
+
import type { OutboundMessage, SendReceipt } from "@intx/types/runtime";
|
|
3
|
+
import type { ConnectorReplyParts } from "./connector-router.js";
|
|
4
|
+
/**
|
|
5
|
+
* The agent event stream the drain consumes -- exactly `agent.stream()`'s
|
|
6
|
+
* type. The stream yields the reactor's full emitted-event union (wider than
|
|
7
|
+
* `InferenceEvent`: it also carries `message.received`), so the drain accepts
|
|
8
|
+
* that union and lets every non-`connector.reply` event flow past untouched.
|
|
9
|
+
*/
|
|
10
|
+
export type AgentEventStream = ReturnType<Agent["stream"]>;
|
|
11
|
+
export interface ConnectorReplyDrainOpts {
|
|
12
|
+
/** The agent event stream to drain. Each `connector.reply` sends a reply. */
|
|
13
|
+
stream: AgentEventStream;
|
|
14
|
+
/**
|
|
15
|
+
* Produce the threading headers (`to`, `cc`, `inReplyTo`, `subject`) for
|
|
16
|
+
* the active connector thread. Throws when no thread is active; the throw
|
|
17
|
+
* is caught per reply and routed to `onSendFailed`.
|
|
18
|
+
*/
|
|
19
|
+
composeReply: () => ConnectorReplyParts;
|
|
20
|
+
/**
|
|
21
|
+
* Send the composed reply. The drain builds the `OutboundMessage` from
|
|
22
|
+
* `composeReply()`'s parts plus the reply content and a
|
|
23
|
+
* `conversation.message` type; the caller's `send` routes it to the
|
|
24
|
+
* transport / outbound bridge.
|
|
25
|
+
*/
|
|
26
|
+
send: (message: OutboundMessage) => Promise<SendReceipt>;
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the full RFC 5322 References chain for a reply whose parent is
|
|
29
|
+
* `inReplyTo` (the Message-Id of the message being answered). Returns the
|
|
30
|
+
* parent's own References plus the parent's Message-Id, in order, so the
|
|
31
|
+
* outbound reply carries the complete conversational ancestry rather than
|
|
32
|
+
* a truncated single element. Returns `undefined` when the parent cannot be
|
|
33
|
+
* located (the very first reply on a fresh thread, or a malformed id); the
|
|
34
|
+
* drain then omits `references` and the transport derives `[inReplyTo]`.
|
|
35
|
+
*
|
|
36
|
+
* Optional: a caller with no mailbox to consult (`createHarness`) omits it,
|
|
37
|
+
* leaving the pre-existing single-element threading unchanged. The warm
|
|
38
|
+
* workflow-host wiring supplies it from the deployment's committed mailbox.
|
|
39
|
+
*/
|
|
40
|
+
resolveReferences?: (inReplyTo: string) => Promise<string[] | undefined>;
|
|
41
|
+
/**
|
|
42
|
+
* Advance connector state after a successful send. May be synchronous
|
|
43
|
+
* (the in-process router's `onReplySent`) or asynchronous (a durable
|
|
44
|
+
* store that persists the advanced `lastMessageId`); the drain awaits it
|
|
45
|
+
* before composing the next reply.
|
|
46
|
+
*/
|
|
47
|
+
onReplySent: (receipt: SendReceipt) => void | Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Invoked when `composeReply`, `send`, or `onReplySent` throws for one
|
|
50
|
+
* reply. The reply is dropped and the connector thread stays at its
|
|
51
|
+
* pre-send value. The drain awaits the callback (so an async callback's
|
|
52
|
+
* rejection is observed and logged, not left as an unhandled rejection)
|
|
53
|
+
* and absorbs any error it raises.
|
|
54
|
+
*/
|
|
55
|
+
onSendFailed?: (cause: unknown) => void | Promise<void>;
|
|
56
|
+
/**
|
|
57
|
+
* Invoked when the stream's `for await` loop exits abnormally -- the
|
|
58
|
+
* documented case is a backpressure error thrown by the agent event
|
|
59
|
+
* stream. After it fires the drain no longer forwards replies. Awaited
|
|
60
|
+
* and absorbed the same way as `onSendFailed`.
|
|
61
|
+
*/
|
|
62
|
+
onTerminated?: (cause: unknown) => void | Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The settled outcome of one reply the drain processed. `ok` distinguishes a
|
|
66
|
+
* durably-sent reply (the send acked and `onReplySent` advanced the thread)
|
|
67
|
+
* from a failed one (compose, send, or `onReplySent` threw). A caller gating a
|
|
68
|
+
* side effect on the reply reaching the transport awaits the barrier and acts
|
|
69
|
+
* only on `ok: true`; `ok: false` carries the failure `cause` so the caller can
|
|
70
|
+
* surface it rather than treat the reply as sent.
|
|
71
|
+
*/
|
|
72
|
+
export type ReplySettlement = {
|
|
73
|
+
readonly ok: true;
|
|
74
|
+
readonly receipt: SendReceipt;
|
|
75
|
+
} | {
|
|
76
|
+
readonly ok: false;
|
|
77
|
+
readonly cause: unknown;
|
|
78
|
+
};
|
|
79
|
+
export interface ConnectorReplyDrain {
|
|
80
|
+
/**
|
|
81
|
+
* Settles once the drain loop has exited and its last pending reply has
|
|
82
|
+
* drained. Always resolves -- per-reply and terminal failures are routed
|
|
83
|
+
* to the callbacks, never thrown out of here -- so a caller can await it
|
|
84
|
+
* on teardown without guarding a rejection.
|
|
85
|
+
*/
|
|
86
|
+
readonly done: Promise<void>;
|
|
87
|
+
/**
|
|
88
|
+
* Signal the loop to stop at the next event. The loop also exits on its
|
|
89
|
+
* own when the underlying stream ends (e.g. the agent closes); `stop()`
|
|
90
|
+
* is the cooperative early exit for a caller tearing down before then.
|
|
91
|
+
*/
|
|
92
|
+
stop(): void;
|
|
93
|
+
/**
|
|
94
|
+
* The count of replies that have SETTLED so far -- sent-and-acked or failed.
|
|
95
|
+
* Monotonic. A per-turn caller captures this BEFORE the `agent.send` that may
|
|
96
|
+
* produce a reply, then, for a turn that did produce a `connector.reply`,
|
|
97
|
+
* awaits `waitForReplyAfter(captured)` to block until THIS turn's reply
|
|
98
|
+
* settles. The capture-before-send ordering is required: the agent resolves
|
|
99
|
+
* `agent.send` in the same synchronous step that pushes the `connector.reply`
|
|
100
|
+
* onto this drain's stream, so the reply is not yet enqueued when `send`
|
|
101
|
+
* resolves -- a post-send snapshot would miss it.
|
|
102
|
+
*/
|
|
103
|
+
replySeq(): number;
|
|
104
|
+
/**
|
|
105
|
+
* Resolve once more than `n` replies have settled -- i.e. the reply at index
|
|
106
|
+
* `n` (the `(n + 1)`th reply the drain processed) has settled -- with that
|
|
107
|
+
* reply's settlement. Because the warm agent is strictly serial and the drain
|
|
108
|
+
* is FIFO, a turn that captured `n` from `replySeq()` before its send and
|
|
109
|
+
* produced exactly one reply awaits reply `n` here.
|
|
110
|
+
*
|
|
111
|
+
* When the drain loop exits (stream end, `stop()`, or an abnormal
|
|
112
|
+
* termination) before reply `n` settles, resolves with a failure settlement
|
|
113
|
+
* rather than hanging, so a caller awaiting a reply that will never arrive
|
|
114
|
+
* fails its turn instead of blocking forever.
|
|
115
|
+
*/
|
|
116
|
+
waitForReplyAfter(n: number): Promise<ReplySettlement>;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Drive an agent's `connector.reply` events out through a transport. Returns
|
|
120
|
+
* immediately with a handle; the drain runs in the background until the
|
|
121
|
+
* stream ends or `stop()` is called.
|
|
122
|
+
*/
|
|
123
|
+
export declare function driveConnectorReplies(opts: ConnectorReplyDrainOpts): ConnectorReplyDrain;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Shared connector reply drain for the agent harness.
|
|
2
|
+
//
|
|
3
|
+
// A director emits a `connector.reply` event when the agent produces an
|
|
4
|
+
// outbound reply on its connector thread. Draining that event means:
|
|
5
|
+
// compose the threading headers for the active thread, send the reply
|
|
6
|
+
// through the transport, then advance the thread's `lastMessageId` from
|
|
7
|
+
// the send receipt. This module owns that loop so both the harness
|
|
8
|
+
// composition layer (`createHarness`) and the warm workflow-host agent
|
|
9
|
+
// path drive replies through one implementation rather than each keeping
|
|
10
|
+
// its own copy.
|
|
11
|
+
//
|
|
12
|
+
// The loop subscribes an agent event stream and serializes every reply
|
|
13
|
+
// through a single chain: two replies fired in quick succession do not
|
|
14
|
+
// interleave their compose / send / onReplySent sequence -- the second
|
|
15
|
+
// waits for the first's receipt to advance the thread before composing
|
|
16
|
+
// against it. A per-reply failure (compose, send, or onReplySent) is
|
|
17
|
+
// surfaced to `onSendFailed` and the reply is dropped with the thread left
|
|
18
|
+
// at its pre-send state; an abnormal stream termination (e.g. an agent
|
|
19
|
+
// stream backpressure violation) is surfaced to `onTerminated`. Neither
|
|
20
|
+
// escapes the returned `done` promise -- it always resolves -- so a caller
|
|
21
|
+
// can await teardown without guarding a rejection.
|
|
22
|
+
import { getLogger } from "@intx/log";
|
|
23
|
+
const logger = getLogger(["interchange", "harness", "reply-drain"]);
|
|
24
|
+
async function invokeAbsorbing(callback, cause, label) {
|
|
25
|
+
try {
|
|
26
|
+
await callback(cause);
|
|
27
|
+
}
|
|
28
|
+
catch (callbackError) {
|
|
29
|
+
logger.error `${label} callback threw: ${callbackError}`;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Drive an agent's `connector.reply` events out through a transport. Returns
|
|
34
|
+
* immediately with a handle; the drain runs in the background until the
|
|
35
|
+
* stream ends or `stop()` is called.
|
|
36
|
+
*/
|
|
37
|
+
export function driveConnectorReplies(opts) {
|
|
38
|
+
let stopped = false;
|
|
39
|
+
// Reply sends are serialized through `replyChain` so two replies fired in
|
|
40
|
+
// quick succession do not interleave their compose / send / onReplySent
|
|
41
|
+
// sequence -- the second waits for the first's receipt to advance the
|
|
42
|
+
// thread before composing its own.
|
|
43
|
+
let replyChain = Promise.resolve();
|
|
44
|
+
// Per-turn settle barrier. `settlements[i]` is the outcome of the `i`th reply
|
|
45
|
+
// the drain processed; `settlements.length` is the monotonic settled count a
|
|
46
|
+
// caller snapshots through `replySeq()`. Waiters block until the settled
|
|
47
|
+
// count passes their target index, then resolve with that reply's outcome. A
|
|
48
|
+
// reply is recorded here on BOTH success and failure so a waiter never hangs;
|
|
49
|
+
// the outcome's `ok` tells the caller which happened.
|
|
50
|
+
const settlements = [];
|
|
51
|
+
let terminated = false;
|
|
52
|
+
let waiters = [];
|
|
53
|
+
const terminalSettlement = () => ({
|
|
54
|
+
ok: false,
|
|
55
|
+
cause: new Error("connector reply drain terminated before the reply was sent"),
|
|
56
|
+
});
|
|
57
|
+
function settlementAt(index) {
|
|
58
|
+
const settlement = settlements[index];
|
|
59
|
+
if (settlement === undefined) {
|
|
60
|
+
// Reached only if a waiter resolves for an index the drain never
|
|
61
|
+
// recorded -- an internal invariant break, surfaced loudly rather than
|
|
62
|
+
// handed back as a silent fallback.
|
|
63
|
+
throw new Error(`connector reply drain: settlement ${String(index)} missing though ` +
|
|
64
|
+
`${String(settlements.length)} replies have settled`);
|
|
65
|
+
}
|
|
66
|
+
return settlement;
|
|
67
|
+
}
|
|
68
|
+
function recordSettlement(settlement) {
|
|
69
|
+
settlements.push(settlement);
|
|
70
|
+
const settledCount = settlements.length;
|
|
71
|
+
const stillWaiting = [];
|
|
72
|
+
for (const waiter of waiters) {
|
|
73
|
+
if (settledCount > waiter.target) {
|
|
74
|
+
waiter.resolve(settlementAt(waiter.target));
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
stillWaiting.push(waiter);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
waiters = stillWaiting;
|
|
81
|
+
}
|
|
82
|
+
function releaseWaitersOnTermination() {
|
|
83
|
+
terminated = true;
|
|
84
|
+
const outstanding = waiters;
|
|
85
|
+
waiters = [];
|
|
86
|
+
for (const waiter of outstanding) {
|
|
87
|
+
// A waiter whose reply settled before teardown gets its real outcome; one
|
|
88
|
+
// whose reply never arrived (the drain stopped first) gets a terminal
|
|
89
|
+
// failure so the caller fails its turn rather than blocking.
|
|
90
|
+
waiter.resolve(settlements.length > waiter.target
|
|
91
|
+
? settlementAt(waiter.target)
|
|
92
|
+
: terminalSettlement());
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const done = (async () => {
|
|
96
|
+
try {
|
|
97
|
+
for await (const event of opts.stream) {
|
|
98
|
+
if (stopped)
|
|
99
|
+
break;
|
|
100
|
+
if (event.type !== "connector.reply")
|
|
101
|
+
continue;
|
|
102
|
+
const content = event.data.content;
|
|
103
|
+
replyChain = replyChain.then(async () => {
|
|
104
|
+
try {
|
|
105
|
+
const parts = opts.composeReply();
|
|
106
|
+
// Resolve the full References ancestry for the parent this reply
|
|
107
|
+
// answers, when the caller supplies a resolver. A resolver miss
|
|
108
|
+
// (parent absent, malformed id) yields `undefined`, and the
|
|
109
|
+
// transport derives `[inReplyTo]` as before.
|
|
110
|
+
const references = opts.resolveReferences !== undefined
|
|
111
|
+
? await opts.resolveReferences(parts.inReplyTo)
|
|
112
|
+
: undefined;
|
|
113
|
+
const receipt = await opts.send({
|
|
114
|
+
...parts,
|
|
115
|
+
content,
|
|
116
|
+
type: "conversation.message",
|
|
117
|
+
...(references !== undefined && references.length > 0
|
|
118
|
+
? { references }
|
|
119
|
+
: {}),
|
|
120
|
+
});
|
|
121
|
+
await opts.onReplySent(receipt);
|
|
122
|
+
recordSettlement({ ok: true, receipt });
|
|
123
|
+
}
|
|
124
|
+
catch (cause) {
|
|
125
|
+
// The reply is dropped and the connector thread stays at its
|
|
126
|
+
// pre-send value. Surface the loss to `onSendFailed` in addition
|
|
127
|
+
// to the operator-facing log so programmatic consumers (retries,
|
|
128
|
+
// alerting) can observe what the log alone hides. Record the
|
|
129
|
+
// failure on the barrier too, so a per-turn caller awaiting this
|
|
130
|
+
// reply sees `ok: false` rather than treating it as sent.
|
|
131
|
+
logger.error `Failed to send connector reply: ${cause}`;
|
|
132
|
+
if (opts.onSendFailed !== undefined) {
|
|
133
|
+
await invokeAbsorbing(opts.onSendFailed, cause, "onSendFailed");
|
|
134
|
+
}
|
|
135
|
+
recordSettlement({ ok: false, cause });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch (cause) {
|
|
141
|
+
// The agent's stream throws on backpressure violations; log and exit.
|
|
142
|
+
// The reply path stops working but the caller's other consumers keep
|
|
143
|
+
// running until teardown. Surface the loss to `onTerminated` so
|
|
144
|
+
// programmatic consumers (alerting, watchdogs) can observe it.
|
|
145
|
+
logger.warn `Reply-drain stream terminated: ${cause}`;
|
|
146
|
+
if (opts.onTerminated !== undefined) {
|
|
147
|
+
await invokeAbsorbing(opts.onTerminated, cause, "onTerminated");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
// Drain the pending reply before the loop exits so its settlement is
|
|
152
|
+
// recorded and a caller awaiting `done` sees a settled state. Then
|
|
153
|
+
// release any barrier waiter still blocked on a reply that will never
|
|
154
|
+
// arrive, so a per-turn caller cannot hang past teardown.
|
|
155
|
+
await replyChain;
|
|
156
|
+
releaseWaitersOnTermination();
|
|
157
|
+
}
|
|
158
|
+
})();
|
|
159
|
+
return {
|
|
160
|
+
done,
|
|
161
|
+
stop() {
|
|
162
|
+
stopped = true;
|
|
163
|
+
},
|
|
164
|
+
replySeq() {
|
|
165
|
+
return settlements.length;
|
|
166
|
+
},
|
|
167
|
+
waitForReplyAfter(n) {
|
|
168
|
+
if (settlements.length > n) {
|
|
169
|
+
return Promise.resolve(settlementAt(n));
|
|
170
|
+
}
|
|
171
|
+
if (terminated) {
|
|
172
|
+
return Promise.resolve(terminalSettlement());
|
|
173
|
+
}
|
|
174
|
+
return new Promise((resolve) => {
|
|
175
|
+
waiters.push({ target: n, resolve });
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intx/harness",
|
|
3
|
-
"
|
|
3
|
+
"description": "Mail-transport composition layer over @intx/agent adding INBOX watch and connector routing",
|
|
4
|
+
"version": "0.4.0",
|
|
4
5
|
"license": "LGPL-2.1-only",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"exports": {
|
|
@@ -11,14 +12,15 @@
|
|
|
11
12
|
}
|
|
12
13
|
},
|
|
13
14
|
"dependencies": {
|
|
14
|
-
"@intx/agent": "0.
|
|
15
|
-
"@intx/
|
|
16
|
-
"@intx/
|
|
15
|
+
"@intx/agent": "0.4.0",
|
|
16
|
+
"@intx/authz": "0.4.0",
|
|
17
|
+
"@intx/log": "0.4.0",
|
|
18
|
+
"@intx/types": "0.4.0"
|
|
17
19
|
},
|
|
18
20
|
"devDependencies": {
|
|
19
|
-
"@intx/inference-testing": "0.
|
|
20
|
-
"@intx/mime": "0.
|
|
21
|
-
"@intx/storage-isogit": "0.
|
|
21
|
+
"@intx/inference-testing": "0.4.0",
|
|
22
|
+
"@intx/mime": "0.4.0",
|
|
23
|
+
"@intx/storage-isogit": "0.4.0",
|
|
22
24
|
"arktype": "^2.1.29"
|
|
23
25
|
},
|
|
24
26
|
"files": [
|