@agen-ai/agent-runtime 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,7 @@ its binding and conversation-local operations.
9
9
  The runtime depends only on `@agen-ai/agent-protocol`. It has no concept of tenants, SaaS
10
10
  workspaces, assigned users, database rows, persistence sequence, visibility, billing, host boots,
11
11
  leases, or storage policy. A host must authorize and select an instance before calling this SPI.
12
+ The current coordinated deployment tuple is Agent Protocol V7, private host V15/catalog V9, and Workspaces event V9.
12
13
 
13
14
  ## Entrypoints
14
15
 
@@ -38,6 +39,12 @@ exactly once before returning the matching session. Capability-dependent operati
38
39
  `supported`/`unsupported` discriminants, and the runtime rejects handlers that disagree with the
39
40
  instance capability declaration.
40
41
 
42
+ Approval continuations are refusal-first. Before delegating to the candidate adapter, the
43
+ validated session proves that the request is still pending and unexpired and that a selected
44
+ `optionId` was offered by that exact request. Provider-emitted approval requests must correlate to
45
+ a live item or exact proposed-plan artifact, and every option must fit an advertised
46
+ persistence/scope mode.
47
+
41
48
  The host should serialize mutating operations for a given session. Separate session objects may
42
49
  run concurrently, so provider implementations must isolate their conversation-local state.
43
50
  `runTurn` and `resolveRequest` both preserve consumer backpressure: the provider does not resume
@@ -53,6 +60,12 @@ turn terminalized; unless the result also carries a terminal event, the validate
53
60
  unusable and must be closed and rematerialized. A close failure makes the session unusable while
54
61
  leaving close itself retryable.
55
62
 
63
+ When context usage is advertised, the validated session enforces the declared measurement scopes
64
+ and cumulative fields across turns. Identical consecutive samples and decreasing cumulative
65
+ counters are rejected. Occupancy may decrease only after a completed advertised compaction item;
66
+ the next accepted sample consumes that allowance. Context output remains subject to the same
67
+ per-output backpressure and terminal ordering as every other provider event.
68
+
56
69
  When `capabilities.turns.steer` is true, the session exposes `steering.steerTurn`. Steering accepts
57
70
  the existing turn ID plus the same canonical `parts` and optional `summary` used to start a turn.
58
71
  It does not create or own an output stream: model output continues on the original `runTurn`
@@ -123,12 +136,13 @@ turn/request ordering, request resolution, steering, interruption, configuration
123
136
  close, and idempotent disposal. Unsupported operations must remain explicit discriminants and
124
137
  must not expose handlers.
125
138
 
126
- The package is at `0.1.0` while the public SPI is being proven with external adapters. Minor
139
+ The package is at `0.2.0` while the public SPI is being proven with external adapters. Minor
127
140
  releases may include breaking changes during this beta period, and those changes will be called out
128
141
  in the release notes.
129
142
 
130
- The current version tuple is Agent Protocol V6, private host V14/catalog V8, and Workspaces event V8.
131
- The runtime package version remains independent of Agent Protocol V6.
143
+ The public runtime implements Agent Protocol V7. Its package version remains independent of private
144
+ host, catalog, persistence, and member-projection versions. Version `0.2.0` directly replaces the
145
+ V6/`0.1.0` API; see the repository `MIGRATING-TO-0.2.md` guide.
132
146
 
133
147
  Run the clean packed-consumer proof before any release:
134
148
 
@@ -51,12 +51,43 @@ function isContentStreamCapabilitySupported(capabilities, streamKind) {
51
51
  }
52
52
  return unsupportedCapabilitySemantic(streamKind);
53
53
  }
54
+ function isApprovalRequestCapabilitySupported(capabilities, event) {
55
+ const request = event.payload.request;
56
+ if (request.requestKind !== "approval") return false;
57
+ const capability = capabilities.requests.approval;
58
+ return capability.kind === "supported" && request.options.every(
59
+ (option) => capability.modes.some(
60
+ (mode) => mode.persistence === option.persistence && mode.scopeKinds.includes(option.scope.kind)
61
+ )
62
+ );
63
+ }
64
+ function isContextUsageCapabilitySupported(capabilities, event) {
65
+ const capability = capabilities.context.usage;
66
+ if (capability.kind !== "supported" || !capability.measurementScopes.includes(
67
+ event.payload.measurementScope
68
+ )) {
69
+ return false;
70
+ }
71
+ if (event.payload.cumulative !== void 0 && Object.keys(event.payload.cumulative).some(
72
+ (field) => !capability.cumulativeFields.includes(
73
+ field
74
+ )
75
+ )) {
76
+ return false;
77
+ }
78
+ return event.payload.compaction === void 0 || capabilities.context.compaction.kind === "supported";
79
+ }
54
80
  function isEventCapabilitySupported(capabilities, event) {
55
81
  switch (event.type) {
56
82
  case "item.started":
57
83
  case "item.updated":
58
- case "item.completed":
84
+ case "item.completed": {
85
+ if (event.payload.itemKind === "context_compaction") {
86
+ const compaction = capabilities.context.compaction;
87
+ return compaction.kind === "supported" && compaction.triggers.includes(event.payload.details.trigger);
88
+ }
59
89
  return isItemKindCapabilitySupported(capabilities, event.payload.itemKind);
90
+ }
60
91
  case "content.delta":
61
92
  return capabilities.output.streaming && isContentStreamCapabilitySupported(
62
93
  capabilities,
@@ -72,9 +103,11 @@ function isEventCapabilitySupported(capabilities, event) {
72
103
  event.payload.artifact.kind
73
104
  );
74
105
  case "request.opened":
75
- return event.payload.request.requestKind === "approval" ? capabilities.requests.approval : capabilities.requests.elicitation.kind === "structured" || capabilities.requests.elicitation.kind === "text" && event.payload.request.fields.every(
106
+ return event.payload.request.requestKind === "approval" ? isApprovalRequestCapabilitySupported(capabilities, event) : capabilities.requests.elicitation.kind === "structured" || capabilities.requests.elicitation.kind === "text" && event.payload.request.fields.every(
76
107
  (field) => field.kind === "text"
77
108
  );
109
+ case "context.usage.updated":
110
+ return isContextUsageCapabilitySupported(capabilities, event);
78
111
  case "turn.started":
79
112
  case "turn.state_changed":
80
113
  case "turn.completed":
@@ -64,8 +64,63 @@ const FINAL_DIFF_TRAILING_EVENT_TYPES = [
64
64
  "runtime.warning",
65
65
  "turn.completed"
66
66
  ];
67
+ function assertApprovalSubjectCorrelation(input) {
68
+ const request = input.event.payload.request;
69
+ if (request.requestKind !== "approval") return;
70
+ if (request.subject.kind === "plan") {
71
+ if (input.state.proposedPlans.get(request.subject.artifactId) !== request.requestId) {
72
+ invalidTurnSequence(
73
+ input.providerKey,
74
+ "Provider approval request does not identify a proposed plan from this turn."
75
+ );
76
+ }
77
+ return;
78
+ }
79
+ const status = input.state.observedItems.get(request.subject.itemId);
80
+ if (status !== "pending" && status !== "in_progress") {
81
+ invalidTurnSequence(
82
+ input.providerKey,
83
+ "Provider approval request does not identify a live item from this turn."
84
+ );
85
+ }
86
+ }
87
+ function observeContextUsage(input) {
88
+ const previous = input.state.latestByScope.get(input.usage.measurementScope);
89
+ if (previous !== void 0) {
90
+ if (JSON.stringify(previous) === JSON.stringify(input.usage)) {
91
+ invalidTurnSequence(
92
+ input.providerKey,
93
+ "Provider emitted a duplicate context usage sample."
94
+ );
95
+ }
96
+ for (const [field, value] of Object.entries(input.usage.cumulative ?? {})) {
97
+ const previousValue = previous.cumulative?.[field];
98
+ if (previousValue !== void 0 && value < previousValue) {
99
+ invalidTurnSequence(
100
+ input.providerKey,
101
+ "Provider context cumulative counters cannot decrease."
102
+ );
103
+ }
104
+ }
105
+ if (input.usage.usedTokens < previous.usedTokens && !input.state.compactionReadyScopes.has(input.usage.measurementScope)) {
106
+ invalidTurnSequence(
107
+ input.providerKey,
108
+ "Provider context occupancy cannot decrease without completed compaction."
109
+ );
110
+ }
111
+ }
112
+ input.state.latestByScope.set(input.usage.measurementScope, input.usage);
113
+ input.state.compactionReadyScopes.delete(input.usage.measurementScope);
114
+ }
67
115
  function observeTurnEvent(input) {
68
- const { event, openedRequestIds, pendingRequests, providerKey, state } = input;
116
+ const {
117
+ contextUsageState,
118
+ event,
119
+ openedRequestIds,
120
+ pendingRequests,
121
+ providerKey,
122
+ state
123
+ } = input;
69
124
  if (state.terminal) {
70
125
  invalidTurnSequence(
71
126
  providerKey,
@@ -108,6 +163,24 @@ function observeTurnEvent(input) {
108
163
  );
109
164
  }
110
165
  }
166
+ if (event.type === "item.started" || event.type === "item.updated" || event.type === "item.completed") {
167
+ state.observedItems.set(event.payload.itemId, event.payload.status);
168
+ if (event.payload.itemKind === "context_compaction" && event.type === "item.completed" && event.payload.status === "completed") {
169
+ for (const scope of ["session", "materialization"]) {
170
+ contextUsageState.compactionReadyScopes.add(scope);
171
+ }
172
+ }
173
+ }
174
+ if (event.type === "turn.plan.proposed") {
175
+ state.proposedPlans.set(event.payload.artifactId, event.payload.requestId);
176
+ }
177
+ if (event.type === "context.usage.updated") {
178
+ observeContextUsage({
179
+ providerKey,
180
+ usage: event.payload,
181
+ state: contextUsageState
182
+ });
183
+ }
111
184
  if (event.type === "request.opened") {
112
185
  if (pendingRequests.size > 0) {
113
186
  invalidTurnSequence(
@@ -122,6 +195,7 @@ function observeTurnEvent(input) {
122
195
  "Provider reused a request ID within a session."
123
196
  );
124
197
  }
198
+ assertApprovalSubjectCorrelation({ providerKey, event, state });
125
199
  openedRequestIds.add(requestId);
126
200
  pendingRequests.set(requestId, {
127
201
  request: event.payload.request,
@@ -182,7 +256,8 @@ function observeTurnOperationOutputs(input) {
182
256
  event: output.event,
183
257
  state: input.state,
184
258
  pendingRequests: input.pendingRequests,
185
- openedRequestIds: input.openedRequestIds
259
+ openedRequestIds: input.openedRequestIds,
260
+ contextUsageState: input.contextUsageState
186
261
  });
187
262
  }
188
263
  }
@@ -196,6 +271,8 @@ function reconstructWaitingTurn(input) {
196
271
  return {
197
272
  state: {
198
273
  fileChangeMode: input.fileChangeMode,
274
+ observedItems: /* @__PURE__ */ new Map(),
275
+ proposedPlans: /* @__PURE__ */ new Map(),
199
276
  started: true,
200
277
  terminal: false,
201
278
  waiting: true,
@@ -289,6 +366,10 @@ function validateAgentProviderSession(input) {
289
366
  validateSessionPorts(capabilities, input.candidate);
290
367
  const pendingRequests = /* @__PURE__ */ new Map();
291
368
  const openedRequestIds = /* @__PURE__ */ new Set();
369
+ const contextUsageState = {
370
+ latestByScope: /* @__PURE__ */ new Map(),
371
+ compactionReadyScopes: /* @__PURE__ */ new Set()
372
+ };
292
373
  let activeTurn = null;
293
374
  let closePromise = null;
294
375
  let closed = false;
@@ -370,6 +451,8 @@ function validateAgentProviderSession(input) {
370
451
  pendingRequests: /* @__PURE__ */ new Map(),
371
452
  state: {
372
453
  fileChangeMode: capabilities.output.fileChanges,
454
+ observedItems: /* @__PURE__ */ new Map(),
455
+ proposedPlans: /* @__PURE__ */ new Map(),
373
456
  started: false,
374
457
  terminal: false,
375
458
  waiting: false,
@@ -394,7 +477,8 @@ function validateAgentProviderSession(input) {
394
477
  event: output.event,
395
478
  state: currentTurn.state,
396
479
  pendingRequests: currentTurn.pendingRequests,
397
- openedRequestIds
480
+ openedRequestIds,
481
+ contextUsageState
398
482
  });
399
483
  }
400
484
  yield output;
@@ -443,6 +527,13 @@ function validateAgentProviderSession(input) {
443
527
  "Provider request resolution does not match the opened request."
444
528
  );
445
529
  }
530
+ if (pending.request.expiresAt !== void 0 && Date.parse(pending.request.expiresAt) <= Date.now()) {
531
+ throwAgentProviderContractError(
532
+ providerKey,
533
+ "request_resolution_mismatch",
534
+ "Provider request resolution identifies an expired request."
535
+ );
536
+ }
446
537
  const nextPendingRequests = new Map(pendingRequests);
447
538
  nextPendingRequests.delete(resolution.requestId);
448
539
  if (activeTurn !== null) {
@@ -457,6 +548,8 @@ function validateAgentProviderSession(input) {
457
548
  pendingRequests: nextPendingRequests,
458
549
  state: {
459
550
  fileChangeMode: capabilities.output.fileChanges,
551
+ observedItems: /* @__PURE__ */ new Map(),
552
+ proposedPlans: /* @__PURE__ */ new Map(),
460
553
  started: true,
461
554
  terminal: false,
462
555
  waiting: false,
@@ -481,7 +574,8 @@ function validateAgentProviderSession(input) {
481
574
  event: output.event,
482
575
  state: currentTurn.state,
483
576
  pendingRequests: currentTurn.pendingRequests,
484
- openedRequestIds
577
+ openedRequestIds,
578
+ contextUsageState
485
579
  });
486
580
  }
487
581
  yield output;
@@ -555,7 +649,8 @@ function validateAgentProviderSession(input) {
555
649
  outputs: result.outputs,
556
650
  state: targetedTurn.state,
557
651
  pendingRequests: targetedTurn.pendingRequests,
558
- openedRequestIds
652
+ openedRequestIds,
653
+ contextUsageState
559
654
  });
560
655
  if (terminalized && !targetedTurn.state.terminal) {
561
656
  completeTurnSequence({
@@ -2,6 +2,8 @@ import {
2
2
  parseAgentCapabilities,
3
3
  parseAgentInstanceId,
4
4
  parseAgentIsoDateTime,
5
+ parseAgentItemId,
6
+ parseAgentApprovalOptionId,
5
7
  parseAgentProviderConversationId,
6
8
  parseAgentProviderHistoryAnchor,
7
9
  parseAgentProviderKey,
@@ -33,7 +35,7 @@ function defaultCapabilities(providerKey) {
33
35
  }
34
36
  };
35
37
  return parseAgentCapabilities({
36
- protocolVersion: 6,
38
+ protocolVersion: 7,
37
39
  providerKey,
38
40
  sessions: { create: true, resume: true, branch: { kind: "through_turn" } },
39
41
  turns: {
@@ -41,7 +43,17 @@ function defaultCapabilities(providerKey) {
41
43
  interrupt: true,
42
44
  steer: { kind: "supported", input }
43
45
  },
44
- requests: { approval: true, elicitation: { kind: "structured" } },
46
+ requests: {
47
+ approval: {
48
+ kind: "supported",
49
+ modes: [{ persistence: "once", scopeKinds: ["exact_action"] }]
50
+ },
51
+ elicitation: { kind: "structured" }
52
+ },
53
+ context: {
54
+ usage: { kind: "unsupported" },
55
+ compaction: { kind: "unsupported" }
56
+ },
45
57
  input,
46
58
  output: {
47
59
  streaming: true,
@@ -81,14 +93,34 @@ function sessionBinding(state, sessionId) {
81
93
  };
82
94
  }
83
95
  function eventBase(sessionId, turnId, occurredAt) {
84
- return { protocolVersion: 6, sessionId, turnId, occurredAt };
96
+ return { protocolVersion: 7, sessionId, turnId, occurredAt };
85
97
  }
86
98
  function requestForTurn(turnId) {
87
99
  return {
88
100
  requestKind: "approval",
89
101
  requestId: parseAgentRequestId(`fake-request:${turnId}`),
90
102
  prompt: "Approve the deterministic fake operation?",
91
- subject: { kind: "other", title: "Fake provider operation" }
103
+ subject: {
104
+ kind: "other",
105
+ title: "Fake provider operation",
106
+ itemId: parseAgentItemId(`fake-item:${turnId}`)
107
+ },
108
+ options: [
109
+ {
110
+ optionId: parseAgentApprovalOptionId("approval:allow-once"),
111
+ label: "Allow once",
112
+ decision: "approved",
113
+ persistence: "once",
114
+ scope: { kind: "exact_action" }
115
+ },
116
+ {
117
+ optionId: parseAgentApprovalOptionId("approval:deny-once"),
118
+ label: "Deny",
119
+ decision: "denied",
120
+ persistence: "once",
121
+ scope: { kind: "exact_action" }
122
+ }
123
+ ]
92
124
  };
93
125
  }
94
126
  function completedResult(outputs = []) {
@@ -109,6 +141,15 @@ function fakeSession(input) {
109
141
  type: "turn.started",
110
142
  payload: { message: "Fake provider turn started." }
111
143
  });
144
+ yield createAgentEventOutput({
145
+ ...eventBase(input.context.sessionId, turnInput.turnId, occurredAt),
146
+ type: "item.started",
147
+ payload: {
148
+ itemId: parseAgentItemId(`fake-item:${turnInput.turnId}`),
149
+ itemKind: "assistant_message",
150
+ status: "in_progress"
151
+ }
152
+ });
112
153
  if (input.capabilities.output.streaming) {
113
154
  yield createAgentEventOutput({
114
155
  ...eventBase(input.context.sessionId, turnInput.turnId, occurredAt),
@@ -120,7 +161,7 @@ function fakeSession(input) {
120
161
  }
121
162
  });
122
163
  }
123
- if (input.capabilities.requests.approval) {
164
+ if (input.capabilities.requests.approval.kind === "supported") {
124
165
  const request = requestForTurn(turnInput.turnId);
125
166
  pendingRequests.set(request.requestId, {
126
167
  request,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agen-ai/agent-runtime",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "Provider-neutral driver, instance, adapter, session, output, and conformance contracts for coding-agent runtimes.",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "./package.json": "./package.json"
28
28
  },
29
29
  "dependencies": {
30
- "@agen-ai/agent-protocol": "^0.1.0"
30
+ "@agen-ai/agent-protocol": "^0.2.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "tsup": "^8.5.0"