@nylorun/runtime 0.38.0-beta → 0.39.0-beta
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/CHANGELOG.md +70 -0
- package/dist/api/components.d.ts +1726 -80
- package/dist/api/http/routes/agents.js +3 -3
- package/dist/core/turn-manifest.d.ts +23 -0
- package/dist/core/turn-manifest.js +36 -1
- package/dist/gates/policy.d.ts +13 -3
- package/dist/gates/policy.js +17 -4
- package/dist/harness/executors.d.ts +4 -2
- package/dist/harness/executors.js +26 -4
- package/dist/harness-api/record.js +4 -2
- package/dist/harness-api/server.js +37 -13
- package/dist/harness-api/start.js +43 -5
- package/dist/keys/keys.d.ts +6 -3
- package/dist/keys/keys.js +1 -1
- package/dist/management-openapi.json +6 -4
- package/dist/mcp/pool.d.ts +5 -0
- package/dist/mcp/pool.js +1 -1
- package/dist/mcp/snapshot.d.ts +3 -1
- package/dist/mcp/snapshot.js +5 -3
- package/dist/openapi.json +1636 -563
- package/dist/store/postgres/store.js +14 -0
- package/dist/store/types.d.ts +9 -0
- package/dist/tenant/agent-versions.d.ts +13 -5
- package/dist/tenant/agent-versions.js +30 -6
- package/dist/tenant/agents.d.ts +14 -2
- package/dist/tenant/agents.js +51 -13
- package/dist/tenant/commands.js +16 -8
- package/dist/tenant/context.d.ts +30 -1
- package/dist/tenant/session-model.d.ts +35 -6
- package/dist/tenant/session-model.js +77 -9
- package/dist/tenant/sessions.js +13 -3
- package/dist/vault/service.d.ts +44 -3
- package/dist/vault/service.js +68 -27
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
|
@@ -1460,6 +1460,20 @@ class PostgresTx {
|
|
|
1460
1460
|
.orderBy(desc(memoryVersions.id))
|
|
1461
1461
|
.limit(options.limit);
|
|
1462
1462
|
}
|
|
1463
|
+
async memoryWritesSince(agentId, after, sessionId) {
|
|
1464
|
+
this.check();
|
|
1465
|
+
const conditions = [eq(memoryVersions.agentId, agentId)];
|
|
1466
|
+
if (after !== undefined)
|
|
1467
|
+
conditions.push(gt(memoryVersions.id, after));
|
|
1468
|
+
const [row] = await this.db
|
|
1469
|
+
.select({
|
|
1470
|
+
newest: sql `max(${memoryVersions.id})`,
|
|
1471
|
+
others: sql `coalesce(bool_or(${memoryVersions.sessionId} is distinct from ${sessionId}), false)`,
|
|
1472
|
+
})
|
|
1473
|
+
.from(memoryVersions)
|
|
1474
|
+
.where(and(...conditions));
|
|
1475
|
+
return { ...(row?.newest ? { newest: row.newest } : {}), others: row?.others === true };
|
|
1476
|
+
}
|
|
1463
1477
|
async memoryVersion(agentId, id) {
|
|
1464
1478
|
this.check();
|
|
1465
1479
|
const [row] = await this.db
|
package/dist/store/types.d.ts
CHANGED
|
@@ -803,6 +803,15 @@ export interface Tx {
|
|
|
803
803
|
before?: string;
|
|
804
804
|
limit: number;
|
|
805
805
|
}): Promise<MemoryVersionRow[]>;
|
|
806
|
+
/**
|
|
807
|
+
* Of Agent `agentId`'s memory versions after version id `after` (every one without it): the
|
|
808
|
+
* newest's id, and whether any was written other than by session `sessionId` (another session,
|
|
809
|
+
* or the API).
|
|
810
|
+
*/
|
|
811
|
+
memoryWritesSince(agentId: string, after: string | undefined, sessionId: string): Promise<{
|
|
812
|
+
newest?: string;
|
|
813
|
+
others: boolean;
|
|
814
|
+
}>;
|
|
806
815
|
memoryVersion(agentId: string, id: string): Promise<MemoryVersionRow | undefined>;
|
|
807
816
|
/** Copies Agent `from`'s memories to new Agent `to`, each at version 1, with no history (a fork). */
|
|
808
817
|
copyMemories(from: string, to: string, now: string): Promise<number>;
|
|
@@ -21,8 +21,11 @@
|
|
|
21
21
|
* - **A turn takes the Agent's version.** A turn's first segment re-pins its session to the
|
|
22
22
|
* version the Agent runs when that changed since the session's last turn (`repinTurn`), before
|
|
23
23
|
* anything of the turn has run: no effect is journaled for it yet. The turn keeps that version
|
|
24
|
-
* through every later segment, approval and rollover.
|
|
25
|
-
*
|
|
24
|
+
* through every later segment, approval and rollover. What its message asked of the turn's
|
|
25
|
+
* manifest (`Session.turnRequest`) applies again to the new version: its `removeTools`, and its
|
|
26
|
+
* variant, validated again against the new version. A variant that is no variant of it keeps
|
|
27
|
+
* the turn on the version it pinned (`turn.version_kept`), so a variant never widens what the
|
|
28
|
+
* turn may do. A flow's sessions keep the version they pinned.
|
|
26
29
|
*
|
|
27
30
|
* Re-pinning at the first segment, not when the message is accepted, keeps a turn that waits for
|
|
28
31
|
* its compute to start on a version the Agent has at that start.
|
|
@@ -64,9 +67,14 @@ export declare function applyPin(session: Session, pin: SessionPin): void;
|
|
|
64
67
|
* active turn is about to run its first segment and nothing of the turn has run yet: the turn
|
|
65
68
|
* then runs that version. Returns whether it changed the session (the caller saves it).
|
|
66
69
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
+
* The turn's manifest on the new version is what its message asked (`Session.turnRequest`):
|
|
71
|
+
* its variant, validated against the new version, without the tools its `removeTools` names.
|
|
72
|
+
*
|
|
73
|
+
* Leaves the session as it is for a flow and a flow's linked session, a later segment, a turn
|
|
74
|
+
* with any journaled effect, a version whose harness is not the session's (an Agent's harness is
|
|
75
|
+
* fixed for its life, D73), a turn on a variant with no request recorded (accepted by an older
|
|
76
|
+
* Runtime), and a variant that is no variant of the new version: then the turn runs on the
|
|
77
|
+
* version it pinned, and `turn.version_kept` says why.
|
|
70
78
|
*/
|
|
71
79
|
export declare function repinTurn(t: Tx, session: Session): Promise<boolean>;
|
|
72
80
|
/** What a move of `agent` to `target` would do, and the target's runtime resolved. */
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { hashManifest } from "@nylorun/core/compatibility";
|
|
2
2
|
import { loopManifestOf, runtimeOf } from "@nylorun/core/define";
|
|
3
3
|
import { isWorkflowManifest } from "../core/flow-host.js";
|
|
4
|
+
import { turnManifestFor } from "../core/turn-manifest.js";
|
|
5
|
+
import { variantStore } from "./session.js";
|
|
4
6
|
import { canonical } from "../store/canonical.js";
|
|
5
7
|
import { resolveRecipe, sandboxSpecOf } from "../sandbox/resolve.js";
|
|
6
8
|
import { effectiveSandboxConfig, readSandboxConfig } from "../sandbox/tenant-config.js";
|
|
@@ -60,9 +62,14 @@ export function applyPin(session, pin) {
|
|
|
60
62
|
* active turn is about to run its first segment and nothing of the turn has run yet: the turn
|
|
61
63
|
* then runs that version. Returns whether it changed the session (the caller saves it).
|
|
62
64
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
65
|
+
* The turn's manifest on the new version is what its message asked (`Session.turnRequest`):
|
|
66
|
+
* its variant, validated against the new version, without the tools its `removeTools` names.
|
|
67
|
+
*
|
|
68
|
+
* Leaves the session as it is for a flow and a flow's linked session, a later segment, a turn
|
|
69
|
+
* with any journaled effect, a version whose harness is not the session's (an Agent's harness is
|
|
70
|
+
* fixed for its life, D73), a turn on a variant with no request recorded (accepted by an older
|
|
71
|
+
* Runtime), and a variant that is no variant of the new version: then the turn runs on the
|
|
72
|
+
* version it pinned, and `turn.version_kept` says why.
|
|
66
73
|
*/
|
|
67
74
|
export async function repinTurn(t, session) {
|
|
68
75
|
const id = session.environmentId;
|
|
@@ -73,8 +80,9 @@ export async function repinTurn(t, session) {
|
|
|
73
80
|
return false;
|
|
74
81
|
if (!checkpoint || checkpoint.segment !== 0 || checkpoint.turnId !== session.activeTurnId)
|
|
75
82
|
return false;
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
const request = session.turnRequest?.turnId === session.activeTurnId ? session.turnRequest : undefined;
|
|
84
|
+
// A variant whose request is not recorded cannot be validated against another version.
|
|
85
|
+
if (checkpoint.manifestHash !== session.manifestHash && request === undefined)
|
|
78
86
|
return false;
|
|
79
87
|
const agent = await t.environment(id);
|
|
80
88
|
if (!agent)
|
|
@@ -86,10 +94,26 @@ export async function repinTurn(t, session) {
|
|
|
86
94
|
const { pin } = await pinOf(t, agent);
|
|
87
95
|
if (pin.harness !== harnessOf(session))
|
|
88
96
|
return false;
|
|
97
|
+
const turn = request
|
|
98
|
+
? turnManifestFor({ pinned: pin.manifest, pinnedHash: pin.manifestHash, request })
|
|
99
|
+
: { ok: true, manifest: pin.manifest, hash: pin.manifestHash };
|
|
100
|
+
if (!turn.ok) {
|
|
101
|
+
const from = { ...session.definition };
|
|
102
|
+
const to = { name: agent.manifestName, version: agent.manifestVersion };
|
|
103
|
+
await t.event(session.id, session.activeTurnId, "turn.version_kept", {
|
|
104
|
+
reason: "variant_invalid",
|
|
105
|
+
from,
|
|
106
|
+
to,
|
|
107
|
+
message: `The turn manifest the message carried is no variant of ${to.name}@${to.version}, the version Agent ${agent.id} runs now, so this turn runs on ${from.name}@${from.version}. Send a variant of the new version, or name the tools the turn may not use with removeTools.`,
|
|
108
|
+
});
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
89
111
|
const changed = pin.manifestHash !== session.manifestHash;
|
|
90
112
|
applyPin(session, pin);
|
|
91
113
|
session.environmentVersion = agent.version;
|
|
92
|
-
|
|
114
|
+
if (turn.hash !== pin.manifestHash)
|
|
115
|
+
variantStore(session).put(turn.hash, turn.manifest);
|
|
116
|
+
session.checkpoint = { ...session.checkpoint, manifestHash: turn.hash };
|
|
93
117
|
// MCP discovery is per loop manifest: the next one is the new version's.
|
|
94
118
|
if (changed) {
|
|
95
119
|
delete session.mcpSnapshot;
|
package/dist/tenant/agents.d.ts
CHANGED
|
@@ -56,13 +56,25 @@ export declare function planAgentPut(ctx: TenantContext, id: string, body: PutAg
|
|
|
56
56
|
* they are set (track T3). Best effort: the write stands either way.
|
|
57
57
|
*/
|
|
58
58
|
export declare function secretWritten(ctx: TenantContext, id: string): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* A secret write as the vault takes it (`Keys.putEnvironmentSecret`): the typed request, and
|
|
61
|
+
* whether its `via` is the slot's (`viaFromSlot`), so a session follows the slot of the version
|
|
62
|
+
* its Agent runs, rather than this installation's own `via` (Host feature `secret-routing`).
|
|
63
|
+
*/
|
|
64
|
+
export interface SecretWrite {
|
|
65
|
+
readonly request: PutAgentSecretRequest;
|
|
66
|
+
readonly viaFromSlot: boolean;
|
|
67
|
+
}
|
|
59
68
|
/**
|
|
60
69
|
* The secret write `raw` asks of Environment `id` (D64). When what it runs declares secret
|
|
61
70
|
* slots, the name must be one (`409 secret_not_in_manifest`), and the body is `{ value }` (or
|
|
62
71
|
* `{ headers }` for a `headers` slot) with the binding from the slot; a full typed body is
|
|
63
|
-
* accepted only when its binding is the slot's. Without slots, today's typed body.
|
|
72
|
+
* accepted only when its binding is the slot's. Without slots, today's typed body. Either way a
|
|
73
|
+
* `bearer` or `headers` slot's write may add this installation's routing (Host feature
|
|
74
|
+
* `secret-routing`): `via`, which wins over the slot's, and the gateway's `toolPrefix`, which
|
|
75
|
+
* gives way to the slot's (a different one is accepted, with a warning in the log).
|
|
64
76
|
*/
|
|
65
|
-
export declare function secretRequestFor(ctx: TenantContext, id: string, name: string, raw: unknown): Promise<
|
|
77
|
+
export declare function secretRequestFor(ctx: TenantContext, id: string, name: string, raw: unknown): Promise<SecretWrite>;
|
|
66
78
|
/** What a create does for a fork (`POST .../fork`); `copied` is filled in by the create. */
|
|
67
79
|
interface Fork {
|
|
68
80
|
source: string;
|
package/dist/tenant/agents.js
CHANGED
|
@@ -560,15 +560,18 @@ export async function secretWritten(ctx, id) {
|
|
|
560
560
|
* The secret write `raw` asks of Environment `id` (D64). When what it runs declares secret
|
|
561
561
|
* slots, the name must be one (`409 secret_not_in_manifest`), and the body is `{ value }` (or
|
|
562
562
|
* `{ headers }` for a `headers` slot) with the binding from the slot; a full typed body is
|
|
563
|
-
* accepted only when its binding is the slot's. Without slots, today's typed body.
|
|
563
|
+
* accepted only when its binding is the slot's. Without slots, today's typed body. Either way a
|
|
564
|
+
* `bearer` or `headers` slot's write may add this installation's routing (Host feature
|
|
565
|
+
* `secret-routing`): `via`, which wins over the slot's, and the gateway's `toolPrefix`, which
|
|
566
|
+
* gives way to the slot's (a different one is accepted, with a warning in the log).
|
|
564
567
|
*/
|
|
565
568
|
export async function secretRequestFor(ctx, id, name, raw) {
|
|
566
|
-
const
|
|
569
|
+
const write = await parseSecretRequest(ctx, id, name, raw);
|
|
567
570
|
// Within the Tenant's Runtime limits `secretValueChars` and `secretHosts`, read for the write.
|
|
568
|
-
const issues = secretRequestIssues(request, await ctx.store.tx((t) => readRuntimeLimits(t)));
|
|
571
|
+
const issues = secretRequestIssues(write.request, await ctx.store.tx((t) => readRuntimeLimits(t)));
|
|
569
572
|
if (issues.length > 0)
|
|
570
573
|
fail(400, issues.join(" "));
|
|
571
|
-
return
|
|
574
|
+
return write;
|
|
572
575
|
}
|
|
573
576
|
async function parseSecretRequest(ctx, id, name, raw) {
|
|
574
577
|
const { environment, slots } = await ctx.store.tx(async (t) => {
|
|
@@ -576,38 +579,73 @@ async function parseSecretRequest(ctx, id, name, raw) {
|
|
|
576
579
|
return { environment, slots: environment && (await slotsAhead(t, environment)) };
|
|
577
580
|
});
|
|
578
581
|
if (!environment || slots === undefined)
|
|
579
|
-
return PutAgentSecretRequestSchema.parse(raw);
|
|
582
|
+
return { request: PutAgentSecretRequestSchema.parse(raw), viaFromSlot: false };
|
|
580
583
|
const named = `${environment.manifestName}@${environment.targetVersion ?? environment.manifestVersion}`;
|
|
581
584
|
const slot = slots[name] ??
|
|
582
585
|
fail(409, `Agent ${id}'s manifest (${named}) declares no secret ${name}: add the slot to the manifest's runtime section, then move the Agent to the new version.`, { code: "secret_not_in_manifest", details: { declared: Object.keys(slots).sort() } });
|
|
583
586
|
const full = slotRequest(slot);
|
|
587
|
+
const slotVia = typeof full.via === "string" ? full.via : undefined;
|
|
588
|
+
const routed = slot.type === "bearer" || slot.type === "headers";
|
|
589
|
+
/** The write's prefix gives way to the slot's: said in the log when they differ. */
|
|
590
|
+
const prefixed = (toolPrefix) => {
|
|
591
|
+
if (routed && toolPrefix !== undefined && slot.toolPrefix !== undefined && toolPrefix !== slot.toolPrefix)
|
|
592
|
+
ctx.config.logger.warn("secret tool prefix gives way to the manifest's", {
|
|
593
|
+
environmentId: id,
|
|
594
|
+
secret: name,
|
|
595
|
+
manifest: named,
|
|
596
|
+
});
|
|
597
|
+
};
|
|
584
598
|
if (raw !== null && typeof raw === "object" && "type" in raw) {
|
|
585
599
|
const body = PutAgentSecretRequestSchema.parse(raw);
|
|
586
|
-
//
|
|
587
|
-
|
|
588
|
-
|
|
600
|
+
// This installation's routing (`gateway-credentials`, `secret-routing`): its via and the
|
|
601
|
+
// gateway's tool prefix are its own to add.
|
|
602
|
+
const { requestId: _, value: _v, headers: _h, toolPrefix, via, ...binding } = body;
|
|
603
|
+
const { via: _s, ...slotBinding } = full;
|
|
604
|
+
if (canonical(binding) !== canonical(slotBinding))
|
|
589
605
|
fail(409, `Secret ${name}'s type and binding come from Agent ${id}'s manifest: send only its value ({ "value": "…" }).`, {
|
|
590
606
|
code: "secret_not_in_manifest",
|
|
591
607
|
});
|
|
592
|
-
|
|
608
|
+
prefixed(toolPrefix);
|
|
609
|
+
if (!routed || (via !== undefined && via !== slotVia))
|
|
610
|
+
return { request: body, viaFromSlot: false };
|
|
611
|
+
// No via of its own: the slot's, which a session reads from the version its Agent runs.
|
|
612
|
+
return {
|
|
613
|
+
request: PutAgentSecretRequestSchema.parse({ ...body, ...(slotVia === undefined ? {} : { via: slotVia }) }),
|
|
614
|
+
viaFromSlot: slotVia !== undefined,
|
|
615
|
+
};
|
|
593
616
|
}
|
|
594
|
-
const { requestId, value, headers, toolPrefix } = PutAgentSecretValueRequestSchema.parse(raw);
|
|
617
|
+
const { requestId, value, headers, via, toolPrefix } = PutAgentSecretValueRequestSchema.parse(raw);
|
|
618
|
+
if (!routed && (via !== undefined || toolPrefix !== undefined))
|
|
619
|
+
fail(400, `Secret ${name} is an ${slot.type}: via and toolPrefix are for a bearer or headers secret.`);
|
|
620
|
+
if (toolPrefix !== undefined && via === undefined && slotVia === undefined)
|
|
621
|
+
fail(400, `toolPrefix needs via: Secret ${name}'s slot names none, so send one with the value.`);
|
|
622
|
+
prefixed(toolPrefix);
|
|
595
623
|
const withId = {
|
|
596
624
|
...(requestId === undefined ? {} : { requestId }),
|
|
597
625
|
...(toolPrefix === undefined ? {} : { toolPrefix }),
|
|
598
626
|
};
|
|
627
|
+
// The write's via wins over the slot's (`secret-routing`); the URL still picks the credential.
|
|
628
|
+
const routing = via === undefined ? {} : { via };
|
|
629
|
+
const viaFromSlot = via === undefined && slotVia !== undefined;
|
|
599
630
|
if (slot.type === "headers") {
|
|
600
631
|
if (headers === undefined)
|
|
601
632
|
fail(400, `Secret ${name} is a headers credential: send { "headers": { … } }.`);
|
|
602
|
-
return PutAgentSecretRequestSchema.parse({ ...withId, ...full, headers });
|
|
633
|
+
return { request: PutAgentSecretRequestSchema.parse({ ...withId, ...full, ...routing, headers }), viaFromSlot };
|
|
603
634
|
}
|
|
604
635
|
if (value === undefined)
|
|
605
636
|
fail(400, `Secret ${name} takes a value: send { "value": "…" }.`);
|
|
606
|
-
return PutAgentSecretRequestSchema.parse({ ...withId, ...full, value });
|
|
637
|
+
return { request: PutAgentSecretRequestSchema.parse({ ...withId, ...full, ...routing, value }), viaFromSlot };
|
|
607
638
|
}
|
|
608
|
-
/**
|
|
639
|
+
/**
|
|
640
|
+
* A slot's type and binding, as a secret write's fields (without the value). A slot's
|
|
641
|
+
* `toolPrefix` stays the slot's: a session reads it from the version its Agent runs.
|
|
642
|
+
*/
|
|
609
643
|
function slotRequest(slot) {
|
|
610
644
|
const { required: _r, description: _d, ...binding } = slot;
|
|
645
|
+
if ("toolPrefix" in binding) {
|
|
646
|
+
const { toolPrefix: _p, ...rest } = binding;
|
|
647
|
+
return rest;
|
|
648
|
+
}
|
|
611
649
|
return binding;
|
|
612
650
|
}
|
|
613
651
|
/**
|
package/dist/tenant/commands.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
import { randomUUID } from "node:crypto";
|
|
25
25
|
import { createDurableCheckpoint, createFlowCheckpoint, resumeFlowCheckpoint, } from "@nylorun/harness/run";
|
|
26
26
|
import { commandKey, cancelQueuedEffects, flowInteractionOf, foreignInteractionConflict, isWorkflowManifest, planCancelCascade, wakeLinkedWorkflow, } from "../core/flow-host.js";
|
|
27
|
-
import { resolveMessageManifest } from "../core/turn-manifest.js";
|
|
27
|
+
import { resolveMessageManifest, turnManifestFor, turnManifestRequestOf } from "../core/turn-manifest.js";
|
|
28
28
|
import { canonical } from "../store/canonical.js";
|
|
29
29
|
import { lockedSession, } from "./context.js";
|
|
30
30
|
import { fail, HttpError } from "./http.js";
|
|
@@ -388,14 +388,21 @@ async function beginMessageTurn(t, s, id, messages) {
|
|
|
388
388
|
input: first.input,
|
|
389
389
|
});
|
|
390
390
|
else {
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
pinnedHash: s.manifestHash,
|
|
394
|
-
messageManifest: first.command.manifest,
|
|
395
|
-
store: variantStore(s),
|
|
396
|
-
});
|
|
391
|
+
const request = turnManifestRequestOf(first.command);
|
|
392
|
+
const resolved = turnManifestFor({ pinned: s.manifest, pinnedHash: s.manifestHash, request: request ?? {} });
|
|
397
393
|
if (!resolved.ok)
|
|
398
394
|
return fail(400, resolved.message);
|
|
395
|
+
if (resolved.hash !== s.manifestHash)
|
|
396
|
+
variantStore(s).put(resolved.hash, resolved.manifest);
|
|
397
|
+
// A turn's first segment applies the request again to the version it re-pins (`repinTurn`).
|
|
398
|
+
if (request)
|
|
399
|
+
s.turnRequest = {
|
|
400
|
+
turnId,
|
|
401
|
+
...(resolved.variant === undefined ? {} : { manifest: resolved.variant }),
|
|
402
|
+
...(request.removeTools === undefined ? {} : { removeTools: [...request.removeTools] }),
|
|
403
|
+
};
|
|
404
|
+
else
|
|
405
|
+
delete s.turnRequest;
|
|
399
406
|
checkpoint = createDurableCheckpoint({
|
|
400
407
|
manifest: resolved.manifest,
|
|
401
408
|
sessionId: id,
|
|
@@ -456,7 +463,8 @@ export async function startQueuedTurn(ctx, t, s, id) {
|
|
|
456
463
|
const queued = await t.queuedMessages(id);
|
|
457
464
|
if (queued.length === 0)
|
|
458
465
|
return undefined;
|
|
459
|
-
|
|
466
|
+
// Messages that ask the same of their turn's manifest (a variant, `removeTools`) share a turn.
|
|
467
|
+
const manifestOf = (row) => canonical(turnManifestRequestOf(row.command) ?? null);
|
|
460
468
|
const batch = isWorkflowManifest(s.manifest) ? [queued[0]] : [];
|
|
461
469
|
if (batch.length === 0)
|
|
462
470
|
for (const row of queued) {
|
package/dist/tenant/context.d.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* under ownership (§10.6); `abortLocal` aborts an advance running on this process.
|
|
16
16
|
*/
|
|
17
17
|
import type { SessionHistory } from "./history.js";
|
|
18
|
-
import type { IssuerScope, SessionModelChoice, SubjectScope, TenantEnvelope } from "@nylorun/core/contracts";
|
|
18
|
+
import type { IssuerScope, SessionModelChoice, SessionModelRecord, SubjectScope, TenantEnvelope } from "@nylorun/core/contracts";
|
|
19
19
|
import type { DurableCheckpoint, FlowCheckpoint } from "@nylorun/harness/run";
|
|
20
20
|
import type { AgentManifest, JsonValue, SandboxManifest } from "@nylorun/core/define";
|
|
21
21
|
import type { OutboxRequest, SessionStore, Tx } from "../store/types.js";
|
|
@@ -63,6 +63,17 @@ export interface Session {
|
|
|
63
63
|
loop?: Record<string, unknown>;
|
|
64
64
|
/** Validated turn-manifest variants, keyed by hash (loops.md §4.2). */
|
|
65
65
|
variants?: Record<string, AgentManifest>;
|
|
66
|
+
/**
|
|
67
|
+
* What the active turn's first message asked of its manifest: a variant (`manifest`) and the
|
|
68
|
+
* tools it may not use (`removeTools`, Host feature `remove-tools`). A turn's first segment that
|
|
69
|
+
* re-pins the session to its Agent's version applies it again to that version (`repinTurn`).
|
|
70
|
+
* Absent when the message asked for neither.
|
|
71
|
+
*/
|
|
72
|
+
turnRequest?: {
|
|
73
|
+
turnId: string;
|
|
74
|
+
manifest?: AgentManifest;
|
|
75
|
+
removeTools?: string[];
|
|
76
|
+
};
|
|
66
77
|
info?: any;
|
|
67
78
|
status: string;
|
|
68
79
|
activeTurnId: string | null;
|
|
@@ -147,6 +158,24 @@ export interface Session {
|
|
|
147
158
|
* place of the Tenant's while its provider is connected. Absent: the Tenant's model.
|
|
148
159
|
*/
|
|
149
160
|
model?: SessionModelChoice;
|
|
161
|
+
/**
|
|
162
|
+
* The Agent's memory as the session's last turn started (track T5): the newest memory version
|
|
163
|
+
* then (`seen`), and whether that turn's first segment asked the harness to pin the index again
|
|
164
|
+
* (`refresh`: someone else wrote the memory since the turn before). Absent before its first turn.
|
|
165
|
+
*/
|
|
166
|
+
memoryIndex?: {
|
|
167
|
+
turnId: string;
|
|
168
|
+
seen?: string;
|
|
169
|
+
refresh: boolean;
|
|
170
|
+
};
|
|
171
|
+
/** Where `model` came from: `manifest` when its Agent's version named it (`harness.model`). Absent: chosen. */
|
|
172
|
+
modelSource?: "manifest";
|
|
173
|
+
/**
|
|
174
|
+
* The model the session's turns call and why (`SessionModelRecord`, `tenant/session-model.ts`):
|
|
175
|
+
* as the session opened or chose it, and at each segment's start since. Absent while the
|
|
176
|
+
* Tenant has no model, and on a session opened before it was kept.
|
|
177
|
+
*/
|
|
178
|
+
modelRecord?: SessionModelRecord;
|
|
150
179
|
}
|
|
151
180
|
export type AuthScope = {
|
|
152
181
|
kind: "application";
|
|
@@ -4,10 +4,15 @@
|
|
|
4
4
|
* (`Session.model`): the Model Gate reads it at each call (`HostModelVault.readHostModel`) and a
|
|
5
5
|
* turn's start describes it (`turn.start.model`). The Tenant's model, and every other session's,
|
|
6
6
|
* stay as they are. While its provider is disconnected the session uses the Tenant's model again.
|
|
7
|
+
*
|
|
8
|
+
* What its turns call, and why, is kept too (`Session.modelRecord`, `SessionModelRecord`): set when
|
|
9
|
+
* the session opens (the manifest's model, or the Tenant's and why), when it chooses or clears its
|
|
10
|
+
* model, and again at each segment's start, when a provider was connected or disconnected or the
|
|
11
|
+
* Tenant's model changed. Each change writes `session.model`.
|
|
7
12
|
*/
|
|
8
|
-
import { type SessionModelChoice, type SessionModelView } from "@nylorun/core/contracts";
|
|
13
|
+
import { type SessionModelChoice, type SessionModelRecord, type SessionModelView } from "@nylorun/core/contracts";
|
|
9
14
|
import type { Tx } from "../store/types.js";
|
|
10
|
-
import { type SessionAccess, type TenantContext } from "./context.js";
|
|
15
|
+
import { type Session, type SessionAccess, type TenantContext } from "./context.js";
|
|
11
16
|
/** The model the session's next turn uses, and where it comes from. */
|
|
12
17
|
export declare function readSessionModel(ctx: TenantContext, id: string, access: SessionAccess | undefined): Promise<SessionModelView>;
|
|
13
18
|
/**
|
|
@@ -15,12 +20,36 @@ export declare function readSessionModel(ctx: TenantContext, id: string, access:
|
|
|
15
20
|
* Tenant's. A turn calls one model: the choice changes only while no turn runs or waits.
|
|
16
21
|
*/
|
|
17
22
|
export declare function chooseSessionModel(ctx: TenantContext, id: string, choice: SessionModelChoice | undefined, access: SessionAccess | undefined): Promise<SessionModelView>;
|
|
23
|
+
/** Why a session's turns call the Tenant's model in place of the one asked for, and that one. */
|
|
24
|
+
export interface ModelFallback {
|
|
25
|
+
readonly reason: NonNullable<SessionModelRecord["reason"]>;
|
|
26
|
+
readonly requested: NonNullable<SessionModelRecord["requested"]>;
|
|
27
|
+
}
|
|
18
28
|
/**
|
|
19
29
|
* The model a new session starts with (Host feature `agent-model`): the one its version's
|
|
20
|
-
* `harness.model` names, as `chooseSessionModel` would take it, or
|
|
21
|
-
*
|
|
22
|
-
* the Tenant's model applies. Never fails the
|
|
30
|
+
* `harness.model` names, as `chooseSessionModel` would take it (`choice`), or, when it names one
|
|
31
|
+
* the Tenant cannot use now (a provider not connected, a model its catalog does not list), why
|
|
32
|
+
* (`fallback`), so the Tenant's model applies. Undefined when it names none. Never fails the
|
|
33
|
+
* session.
|
|
23
34
|
*/
|
|
24
35
|
export declare function agentModelOf(t: Tx, harness: {
|
|
25
36
|
readonly model?: unknown;
|
|
26
|
-
}): Promise<
|
|
37
|
+
}): Promise<{
|
|
38
|
+
choice: SessionModelChoice;
|
|
39
|
+
} | {
|
|
40
|
+
fallback: ModelFallback;
|
|
41
|
+
} | undefined>;
|
|
42
|
+
/**
|
|
43
|
+
* The model session `s`'s turns call now, and why (`SessionModelRecord`, as the Model Gate picks
|
|
44
|
+
* it: `hostCredentialRowOf`): its own model while its provider is connected (`source` `manifest`
|
|
45
|
+
* when its Agent's version named it, else `session`), else the Tenant's, with the reason when it
|
|
46
|
+
* falls back. `fallback` is why the session opened without the model its manifest names; kept
|
|
47
|
+
* from the session's record otherwise, until it chooses or clears a model. Undefined while the
|
|
48
|
+
* Tenant has no model to fall back to.
|
|
49
|
+
*/
|
|
50
|
+
export declare function sessionModelRecordOf(t: Tx, s: Pick<Session, "model" | "modelSource" | "modelRecord">, fallback?: ModelFallback): Promise<SessionModelRecord | undefined>;
|
|
51
|
+
/**
|
|
52
|
+
* Keeps `record` as session `s`'s model record (the caller saves `s`) and, when it differs from
|
|
53
|
+
* the one it had, writes `session.model` with the one before. Returns whether it changed.
|
|
54
|
+
*/
|
|
55
|
+
export declare function noteSessionModel(t: Tx, s: Session, record: SessionModelRecord | undefined): Promise<boolean>;
|
|
@@ -4,10 +4,16 @@
|
|
|
4
4
|
* (`Session.model`): the Model Gate reads it at each call (`HostModelVault.readHostModel`) and a
|
|
5
5
|
* turn's start describes it (`turn.start.model`). The Tenant's model, and every other session's,
|
|
6
6
|
* stay as they are. While its provider is disconnected the session uses the Tenant's model again.
|
|
7
|
+
*
|
|
8
|
+
* What its turns call, and why, is kept too (`Session.modelRecord`, `SessionModelRecord`): set when
|
|
9
|
+
* the session opens (the manifest's model, or the Tenant's and why), when it chooses or clears its
|
|
10
|
+
* model, and again at each segment's start, when a provider was connected or disconnected or the
|
|
11
|
+
* Tenant's model changed. Each change writes `session.model`.
|
|
7
12
|
*/
|
|
8
|
-
import { agentModelChoice } from "@nylorun/core/contracts";
|
|
13
|
+
import { agentModelChoice, } from "@nylorun/core/contracts";
|
|
14
|
+
import { canonical } from "../store/canonical.js";
|
|
9
15
|
import { catalogModel, hostModelCatalog } from "../model/catalog.js";
|
|
10
|
-
import { hostCredentialRowFor, modelBinding } from "../vault/service.js";
|
|
16
|
+
import { activeHostCredentialRow, hostCredentialRowFor, modelBinding } from "../vault/service.js";
|
|
11
17
|
import { takesMessages } from "./commands.js";
|
|
12
18
|
import { lockedSession, sessionOf } from "./context.js";
|
|
13
19
|
import { fail } from "./http.js";
|
|
@@ -29,8 +35,9 @@ export async function chooseSessionModel(ctx, id, choice, access) {
|
|
|
29
35
|
});
|
|
30
36
|
if (choice)
|
|
31
37
|
await checkChoice(t, choice);
|
|
32
|
-
const { model: _model, ...rest } = s;
|
|
38
|
+
const { model: _model, modelSource: _source, ...rest } = s;
|
|
33
39
|
const next = choice ? { ...rest, model: { provider: choice.provider, model: choice.model } } : rest;
|
|
40
|
+
await noteSessionModel(t, next, await sessionModelRecordOf(t, next));
|
|
34
41
|
await t.put("sessions", id, next);
|
|
35
42
|
return next;
|
|
36
43
|
});
|
|
@@ -38,15 +45,70 @@ export async function chooseSessionModel(ctx, id, choice, access) {
|
|
|
38
45
|
}
|
|
39
46
|
/**
|
|
40
47
|
* The model a new session starts with (Host feature `agent-model`): the one its version's
|
|
41
|
-
* `harness.model` names, as `chooseSessionModel` would take it, or
|
|
42
|
-
*
|
|
43
|
-
* the Tenant's model applies. Never fails the
|
|
48
|
+
* `harness.model` names, as `chooseSessionModel` would take it (`choice`), or, when it names one
|
|
49
|
+
* the Tenant cannot use now (a provider not connected, a model its catalog does not list), why
|
|
50
|
+
* (`fallback`), so the Tenant's model applies. Undefined when it names none. Never fails the
|
|
51
|
+
* session.
|
|
44
52
|
*/
|
|
45
53
|
export async function agentModelOf(t, harness) {
|
|
46
54
|
if (typeof harness.model !== "string")
|
|
47
55
|
return undefined;
|
|
48
56
|
const choice = agentModelChoice(harness.model);
|
|
49
|
-
|
|
57
|
+
if (!choice)
|
|
58
|
+
return undefined;
|
|
59
|
+
const issue = await choiceIssue(t, choice);
|
|
60
|
+
return issue === undefined
|
|
61
|
+
? { choice }
|
|
62
|
+
: { fallback: { reason: issue.reason, requested: { ...choice, by: "manifest" } } };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The model session `s`'s turns call now, and why (`SessionModelRecord`, as the Model Gate picks
|
|
66
|
+
* it: `hostCredentialRowOf`): its own model while its provider is connected (`source` `manifest`
|
|
67
|
+
* when its Agent's version named it, else `session`), else the Tenant's, with the reason when it
|
|
68
|
+
* falls back. `fallback` is why the session opened without the model its manifest names; kept
|
|
69
|
+
* from the session's record otherwise, until it chooses or clears a model. Undefined while the
|
|
70
|
+
* Tenant has no model to fall back to.
|
|
71
|
+
*/
|
|
72
|
+
export async function sessionModelRecordOf(t, s, fallback) {
|
|
73
|
+
if (s.model) {
|
|
74
|
+
const by = s.modelSource ?? "session";
|
|
75
|
+
if (await hostCredentialRowFor(t, s.model.provider))
|
|
76
|
+
return { provider: s.model.provider, model: s.model.model, source: by };
|
|
77
|
+
return tenantModelRecord(t, { reason: "provider_not_connected", requested: { ...s.model, by } });
|
|
78
|
+
}
|
|
79
|
+
const kept = fallback ??
|
|
80
|
+
(s.modelRecord?.reason !== undefined && s.modelRecord.requested?.by === "manifest"
|
|
81
|
+
? { reason: s.modelRecord.reason, requested: s.modelRecord.requested }
|
|
82
|
+
: undefined);
|
|
83
|
+
return tenantModelRecord(t, kept);
|
|
84
|
+
}
|
|
85
|
+
async function tenantModelRecord(t, fallback) {
|
|
86
|
+
const row = await activeHostCredentialRow(t);
|
|
87
|
+
if (!row)
|
|
88
|
+
return undefined;
|
|
89
|
+
const binding = modelBinding(row);
|
|
90
|
+
return {
|
|
91
|
+
provider: binding.provider,
|
|
92
|
+
model: binding.model,
|
|
93
|
+
source: "tenant",
|
|
94
|
+
...(fallback === undefined ? {} : { reason: fallback.reason, requested: { ...fallback.requested } }),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Keeps `record` as session `s`'s model record (the caller saves `s`) and, when it differs from
|
|
99
|
+
* the one it had, writes `session.model` with the one before. Returns whether it changed.
|
|
100
|
+
*/
|
|
101
|
+
export async function noteSessionModel(t, s, record) {
|
|
102
|
+
if (canonical(record ?? null) === canonical(s.modelRecord ?? null))
|
|
103
|
+
return false;
|
|
104
|
+
const previous = s.modelRecord;
|
|
105
|
+
if (record === undefined) {
|
|
106
|
+
delete s.modelRecord;
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
s.modelRecord = record;
|
|
110
|
+
await t.event(s.id, s.activeTurnId, "session.model", { ...record, ...(previous ? { previous } : {}) });
|
|
111
|
+
return true;
|
|
50
112
|
}
|
|
51
113
|
/** A choice names a connected provider and, when its catalog lists models, one of them. */
|
|
52
114
|
async function checkChoice(t, choice) {
|
|
@@ -57,19 +119,25 @@ async function checkChoice(t, choice) {
|
|
|
57
119
|
/** Why `choice` is not one the Tenant can use now; undefined when it is. */
|
|
58
120
|
async function choiceIssue(t, choice) {
|
|
59
121
|
if (!/^[a-zA-Z0-9._-]+$/.test(choice.provider))
|
|
60
|
-
return { status: 400, message: "Invalid provider id", code: "invalid_request" };
|
|
122
|
+
return { status: 400, message: "Invalid provider id", code: "invalid_request", reason: "invalid_model" };
|
|
61
123
|
const row = await hostCredentialRowFor(t, choice.provider);
|
|
62
124
|
if (!row)
|
|
63
125
|
return {
|
|
64
126
|
status: 422,
|
|
65
127
|
message: `Model provider ${choice.provider} is not connected: connect it in Model Settings first`,
|
|
66
128
|
code: "request_rejected",
|
|
129
|
+
reason: "provider_not_connected",
|
|
67
130
|
};
|
|
68
131
|
// A custom endpoint serves the models it names; the provider's own model is always one.
|
|
69
132
|
if (choice.provider === "custom" || modelBinding(row).model === choice.model)
|
|
70
133
|
return undefined;
|
|
71
134
|
const listed = hostModelCatalog().providers.find((provider) => provider.id === choice.provider);
|
|
72
135
|
if (listed && listed.models.length > 0 && !catalogModel(choice.provider, choice.model))
|
|
73
|
-
return {
|
|
136
|
+
return {
|
|
137
|
+
status: 422,
|
|
138
|
+
message: `Unknown model ${choice.model} for provider ${choice.provider}`,
|
|
139
|
+
code: "request_rejected",
|
|
140
|
+
reason: "not_in_catalog",
|
|
141
|
+
};
|
|
74
142
|
return undefined;
|
|
75
143
|
}
|
package/dist/tenant/sessions.js
CHANGED
|
@@ -15,7 +15,7 @@ import { openAgent, recordAttachment } from "./agents.js";
|
|
|
15
15
|
import { harnessOf, reachesAgent, } from "./context.js";
|
|
16
16
|
import { fail } from "./http.js";
|
|
17
17
|
import { definitionFileIssues } from "./definition-files.js";
|
|
18
|
-
import { agentModelOf } from "./session-model.js";
|
|
18
|
+
import { agentModelOf, noteSessionModel, sessionModelRecordOf } from "./session-model.js";
|
|
19
19
|
import { limitsInForce, readTenantLimits } from "./limits.js";
|
|
20
20
|
/** A session's creation identity: everything but the request id. */
|
|
21
21
|
const sessionIdentity = (value) => {
|
|
@@ -98,7 +98,8 @@ export async function openSessionIn(ctx, t, id, body, access, options = {}) {
|
|
|
98
98
|
await requirePods(ctx.pods, `Agent ${agent.id}`);
|
|
99
99
|
// The model the version names (`harness.model`, Host feature `agent-model`) is the new
|
|
100
100
|
// session's own, as `chooseSessionModel` would make it, when the Tenant can use it now.
|
|
101
|
-
const
|
|
101
|
+
const asked = await agentModelOf(t, version.body.harness);
|
|
102
|
+
const model = asked && "choice" in asked ? asked.choice : undefined;
|
|
102
103
|
const created = {
|
|
103
104
|
id,
|
|
104
105
|
agentId: agent.id,
|
|
@@ -115,10 +116,16 @@ export async function openSessionIn(ctx, t, id, body, access, options = {}) {
|
|
|
115
116
|
environmentId: agent.id,
|
|
116
117
|
environmentVersion: agent.version,
|
|
117
118
|
...(pin.sandbox === undefined ? {} : { sandbox: pin.sandbox }),
|
|
118
|
-
...(model === undefined ? {} : { model }),
|
|
119
|
+
...(model === undefined ? {} : { model, modelSource: "manifest" }),
|
|
119
120
|
};
|
|
120
121
|
await t.put("sessions", id, created);
|
|
121
122
|
await recordAttachment(t, id, agent.id);
|
|
123
|
+
// The model its turns call, and why: a flow's agents call theirs in their own sessions.
|
|
124
|
+
if (!isWorkflowManifest(pin.manifest)) {
|
|
125
|
+
const fallback = asked && "fallback" in asked ? asked.fallback : undefined;
|
|
126
|
+
if (await noteSessionModel(t, created, await sessionModelRecordOf(t, created, fallback)))
|
|
127
|
+
await t.put("sessions", id, created);
|
|
128
|
+
}
|
|
122
129
|
return created;
|
|
123
130
|
}
|
|
124
131
|
/**
|
|
@@ -165,6 +172,9 @@ export async function sessionView(t, s) {
|
|
|
165
172
|
...(s.closedReason === undefined ? {} : { closedReason: s.closedReason }),
|
|
166
173
|
mcpSnapshot: s.mcpSnapshot ?? null,
|
|
167
174
|
mcpDiagnostics: s.mcpDiagnostics ?? [],
|
|
175
|
+
// As its last turn started; a session that kept none yet (older, or opened before the
|
|
176
|
+
// Tenant had a model) answers what its next turn would call.
|
|
177
|
+
model: isWorkflowManifest(s.manifest) ? null : (s.modelRecord ?? (await sessionModelRecordOf(t, s)) ?? null),
|
|
168
178
|
waits,
|
|
169
179
|
error: s.error,
|
|
170
180
|
uncertainEffects: uncertain.map((e) => ({
|