@hydraharness/harness-time-context 0.0.0-stage → 0.1.1-rc.7
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/LICENSE +21 -0
- package/README.md +76 -2
- package/lib/index.js +398 -0
- package/lib/invariant.js +302 -0
- package/lib/types/index.d.ts +29 -0
- package/lib/types/invariant.d.ts +13 -0
- package/lib/types/request-zone.d.ts +26 -0
- package/lib/types/timestamp.d.ts +16 -0
- package/package.json +52 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,77 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @hydraharness/harness-time-context
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Opt-in durable context with the current zoned time, the browser zone attached to the open request, and elapsed time sampled during model-request preparation. Default compositions leave it disabled; the Schedule Web overlay mounts it so the model can interpret otherwise-unqualified dates and times in the user's browser zone. Decision record: [the durable time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md).
|
|
4
|
+
|
|
5
|
+
Elapsed-time and browser-zone context use events from the selected transcript version.
|
|
6
|
+
|
|
7
|
+
## Config
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
- id: time-context
|
|
11
|
+
name: '@hydraharness/harness-time-context'
|
|
12
|
+
config:
|
|
13
|
+
timeZone: Asia/Shanghai # optional fallback when the request has no unique browser zone
|
|
14
|
+
refreshIntervalMs: 60000 # optional; omit or set to 0 for every eligible attempt
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
When the open turn contains one Host-validated browser zone, that request-local zone formats the timestamp. With missing or mixed browser provenance, `timeZone` supplies the display fallback; omitting it resolves the Node process zone once at plugin load. Node honors `TZ`, and every explicit fallback is validated through `Intl.DateTimeFormat`.
|
|
18
|
+
|
|
19
|
+
`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` adds context to every eligible entering pre-step whose signal is not already aborted. A positive value adds it only when the Session has no earlier time-context injection, wall time moved backward, or at least that many milliseconds elapsed since the latest injection.
|
|
20
|
+
|
|
21
|
+
## Request-zone ownership
|
|
22
|
+
|
|
23
|
+
The browser samples `Intl.DateTimeFormat().resolvedOptions().timeZone` for each prompt. The Host validates and canonicalizes that value before binding it to the exact durable `user-rpc` message source. Time-context examines only those sources in the open turn: one unique zone resolves the request, multiple zones are `mixed`, and none are `unavailable`. It does not read or mutate Session headers, connection state, or Schedule records.
|
|
24
|
+
|
|
25
|
+
The resolved instruction tells the model to interpret otherwise-unqualified dates and times in that browser zone. Mixed or unavailable provenance tells the model to ask the user to clarify. This is natural-language context, not an input default at another package boundary: a tool that accepts local calendar fields still owns its explicit zone requirement.
|
|
26
|
+
|
|
27
|
+
## Timing semantics
|
|
28
|
+
|
|
29
|
+
The plugin prepends an `agent/pre-step` listener and delegates first. When an injection is due and the downstream decision enters, it appends one sourced `UserMessage` to the returned batch. AgentLoop records the final batch after `step/start` and before request derivation. Rejection, listener failure, or an already-aborted signal records nothing.
|
|
30
|
+
|
|
31
|
+
Each reading uses the exact snapshot source `{ kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections: [{ name: 'time-context', text: <same text> }] }`. The `./invariant` companion validates that shape, re-derives the current-turn browser policy from the original `user-rpc` messages, and checks the timestamp zone and elapsed baseline.
|
|
32
|
+
|
|
33
|
+
Positive-interval scheduling scans raw durable Session events for the latest plugin-attributed message, including a reading shadowed by compaction. It therefore survives resume without a process-local cache. A positive interval can intentionally let a later request reuse existing history without a fresh reading; the Schedule Web overlay omits the interval.
|
|
34
|
+
|
|
35
|
+
Step 1 measures from the latest preceding durable user, assistant, or tool-result message. The prompt proposed for that step has not been appended yet. Later steps measure from the preceding time-context event in the same turn. Missing baselines report `unavailable`, and backward wall-clock movement clamps elapsed time to zero.
|
|
36
|
+
|
|
37
|
+
A reading records an entered step, not a completed or transmitted request. A later preparation failure can leave it in history. The message remains in derived conversation history until compaction shadows it; `request/header` contains no time-context state, and request reconstruction uses the complete durable surface prefix after each `step/start`.
|
|
38
|
+
|
|
39
|
+
## Model Experience
|
|
40
|
+
|
|
41
|
+
### Preparation-time temporal context
|
|
42
|
+
|
|
43
|
+
#### What the model sees
|
|
44
|
+
|
|
45
|
+
Each injected message contains three lines. `<timestamp>` is an ISO-shaped timestamp with numeric offset and IANA zone; durations use compact whole-second units.
|
|
46
|
+
|
|
47
|
+
##### First step
|
|
48
|
+
|
|
49
|
+
```markdown
|
|
50
|
+
Time sampled while preparing turn <turn>, step 1: <timestamp>
|
|
51
|
+
Browser time zone for this request: <iana-zone-or-mixed-or-unavailable-policy>.
|
|
52
|
+
Elapsed since the preceding model-visible message: <duration-or-unavailable>.
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
##### Later steps
|
|
56
|
+
|
|
57
|
+
```markdown
|
|
58
|
+
Time sampled while preparing turn <turn>, step <step>: <timestamp>
|
|
59
|
+
Browser time zone for this request: <iana-zone-or-mixed-or-unavailable-policy>.
|
|
60
|
+
Elapsed since the preceding step context: <duration-or-unavailable>.
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
#### Token effect
|
|
64
|
+
|
|
65
|
+
Each reading accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one at every eligible preparation attempt.
|
|
66
|
+
|
|
67
|
+
#### KV Cache effect
|
|
68
|
+
|
|
69
|
+
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
|
70
|
+
|
|
71
|
+
## Known Limitations and Deferred Work
|
|
72
|
+
|
|
73
|
+
- **Prompt provenance only** — browser-zone context guides natural-language interpretation but does not silently supply another tool's required zone field.
|
|
74
|
+
- **Mixed turns ask** — if one open turn contains prompts from different browser zones, the model is told to clarify rather than guess which one owns an unqualified time.
|
|
75
|
+
- **Fallback is not user authority** — the configured or process zone formats the clock when browser provenance is missing or mixed, but the model-facing policy still says to clarify.
|
|
76
|
+
- **Whole-second display** — timestamps and durations omit sub-second precision even though durable event times retain milliseconds.
|
|
77
|
+
- **History cost between compactions** — omission or `0` retains one reading for every eligible attempt; a positive interval reduces but does not eliminate this cost and may leave a later request without fresh browser-zone guidance.
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import z from "@hydraharness/schemastery";
|
|
3
|
+
import "@hydraharness/cordis";
|
|
4
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
|
+
//#region ../../llm/llm/src/brand.ts
|
|
6
|
+
/**
|
|
7
|
+
* Brand a message identifier.
|
|
8
|
+
* @param id - the opaque message identifier.
|
|
9
|
+
* @returns the same string, branded; no validation is performed.
|
|
10
|
+
*/
|
|
11
|
+
function MessageId(id) {
|
|
12
|
+
return id;
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region ../../llm/llm/src/call-config.ts
|
|
16
|
+
/**
|
|
17
|
+
* Deep-freeze a value in place with an iterative traversal, guarding cycles,
|
|
18
|
+
* so later mutation throws without imposing a JavaScript call-stack depth cap.
|
|
19
|
+
* {@link AbortSignal} objects are deliberately skipped because they are the
|
|
20
|
+
* request's live cancellation channel and freezing them breaks abort.
|
|
21
|
+
* @param value - the value to freeze in place.
|
|
22
|
+
* @returns the same value, frozen.
|
|
23
|
+
*/
|
|
24
|
+
function deepFreeze(value) {
|
|
25
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
26
|
+
const pending = [{
|
|
27
|
+
kind: "visit",
|
|
28
|
+
node: value
|
|
29
|
+
}];
|
|
30
|
+
while (pending.length > 0) {
|
|
31
|
+
const task = pending.pop();
|
|
32
|
+
/* v8 ignore next -- the loop condition guarantees one pending task. */
|
|
33
|
+
if (task === void 0) continue;
|
|
34
|
+
if (task.kind === "property") {
|
|
35
|
+
pending.push({
|
|
36
|
+
kind: "visit",
|
|
37
|
+
node: task.source[task.key]
|
|
38
|
+
});
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const node = task.node;
|
|
42
|
+
if (node === null || typeof node !== "object") continue;
|
|
43
|
+
if (node instanceof AbortSignal) continue;
|
|
44
|
+
if (seen.has(node)) continue;
|
|
45
|
+
seen.add(node);
|
|
46
|
+
Object.freeze(node);
|
|
47
|
+
const keys = Object.keys(node);
|
|
48
|
+
for (let index = keys.length - 1; index >= 0; index--) {
|
|
49
|
+
const key = keys[index];
|
|
50
|
+
/* v8 ignore next -- the loop is bounded by the captured key count. */
|
|
51
|
+
if (key === void 0) continue;
|
|
52
|
+
pending.push({
|
|
53
|
+
kind: "property",
|
|
54
|
+
source: node,
|
|
55
|
+
key
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region ../../llm/llm/src/message.ts
|
|
63
|
+
/** Message value types, identity, and immutable construction helpers. */
|
|
64
|
+
/**
|
|
65
|
+
* Detach and deep-freeze a message whose identity already exists.
|
|
66
|
+
* @param message - complete message, including its stable identity.
|
|
67
|
+
* @returns an immutable snapshot that preserves the identity.
|
|
68
|
+
*/
|
|
69
|
+
function freezeMessage(message) {
|
|
70
|
+
return deepFreeze(structuredClone(message));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Create one identified message and freeze it before publication.
|
|
74
|
+
* @param input - complete role, content, and source for a new message.
|
|
75
|
+
* @returns an immutable message with a fresh stable identity.
|
|
76
|
+
*/
|
|
77
|
+
function createMessage(input) {
|
|
78
|
+
return freezeMessage({
|
|
79
|
+
...input,
|
|
80
|
+
id: MessageId(crypto.randomUUID())
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Create one identified user-role message and freeze it before publication.
|
|
85
|
+
* @param input - complete content and source for a new user message.
|
|
86
|
+
* @returns an immutable user message with a fresh stable identity.
|
|
87
|
+
*/
|
|
88
|
+
function createUserMessage(input) {
|
|
89
|
+
return createMessage({
|
|
90
|
+
...input,
|
|
91
|
+
role: "user"
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
//#endregion
|
|
95
|
+
//#region ../../util/timeout/src/index.ts
|
|
96
|
+
/** Largest delay Node schedules without clamping it to one millisecond. */
|
|
97
|
+
const MAX_TIMER_DELAY_MS = 2147483647;
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region ../../llm/llm/src/error.ts
|
|
100
|
+
/**
|
|
101
|
+
* Canonical provider-neutral code for a response that completed normally but
|
|
102
|
+
* carried no content blocks at all. Providers occasionally emit a degenerate
|
|
103
|
+
* completion (a terminal stop with zero output); adapters classify it as this
|
|
104
|
+
* failure instead of yielding an empty assistant message, because an empty
|
|
105
|
+
* message silently ends the turn with nothing for the user or the loop to act
|
|
106
|
+
* on. The attempt produced nothing durable, so retry policy treats it as safe
|
|
107
|
+
* to repeat.
|
|
108
|
+
*/
|
|
109
|
+
const EMPTY_RESPONSE_CODE = "EMPTY_RESPONSE";
|
|
110
|
+
new RegExp(String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]` + String.raw`(?:exceed(?:ed|s)?|overflow(?:ed)?|limit[\s_-]exceeded)(?:$|[^a-z0-9])`, "i");
|
|
111
|
+
new RegExp(String.raw`\b(?:request|prompt|input|messages?)\s+(?:is\s+|are\s+)?` + String.raw`too\s+(?:large|long)\s+for\s+(?:(?:this|the)\s+)?` + String.raw`(?:model(?:'s)?\s+)?context(?:\s+window)?\b`, "i");
|
|
112
|
+
new RegExp(String.raw`\b(?:input|prompt|request|messages?)\b.{0,40}` + String.raw`\b(?:exceed(?:s|ed)?|overflows?|is\s+larger\s+than)\b.{0,40}` + String.raw`\b(?:the\s+)?(?:model(?:'s)?\s+)?context(?:\s+(?:length|window))?\b`, "i");
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region ../../llm/llm/src/retry-policy.ts
|
|
115
|
+
/**
|
|
116
|
+
* Provider-owned request-retry policy configuration and resolution.
|
|
117
|
+
*
|
|
118
|
+
* Adapters expose one resolved policy per registered provider route; the
|
|
119
|
+
* optional @hydraharness/harness-llm-retry plugin executes it on the agent's failed-step extension point.
|
|
120
|
+
*
|
|
121
|
+
* @module @hydraharness/harness-llm/retry-policy
|
|
122
|
+
*/
|
|
123
|
+
const DEFAULT_MAX_RETRIES = 5;
|
|
124
|
+
const DEFAULT_INITIAL_DELAY_MS = 500;
|
|
125
|
+
const DEFAULT_MAX_DELAY_MS = 1e4;
|
|
126
|
+
const DEFAULT_JITTER_RATIO = .1;
|
|
127
|
+
const DEFAULT_RETRYABLE_CODES = Object.freeze([
|
|
128
|
+
EMPTY_RESPONSE_CODE,
|
|
129
|
+
"RATE_LIMIT",
|
|
130
|
+
"SERVER",
|
|
131
|
+
"TIMEOUT",
|
|
132
|
+
"TRANSPORT"
|
|
133
|
+
]);
|
|
134
|
+
const backoffSchema = z.object({
|
|
135
|
+
initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS),
|
|
136
|
+
maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS),
|
|
137
|
+
jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO)
|
|
138
|
+
});
|
|
139
|
+
const normalPolicySchema = z.object({
|
|
140
|
+
mode: z.const("normal").required(),
|
|
141
|
+
maxRetries: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RETRIES),
|
|
142
|
+
retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]),
|
|
143
|
+
backoff: backoffSchema
|
|
144
|
+
});
|
|
145
|
+
const alwaysPolicySchema = z.object({
|
|
146
|
+
mode: z.const("always").required(),
|
|
147
|
+
backoff: backoffSchema
|
|
148
|
+
});
|
|
149
|
+
z.union([normalPolicySchema, alwaysPolicySchema]);
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region ../../llm/llm/src/attribution.ts
|
|
152
|
+
/**
|
|
153
|
+
* Centralize the non-secret product identity every provider request sends as `User-Agent`, keeping
|
|
154
|
+
* adapters from drifting. See
|
|
155
|
+
* `.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md`.
|
|
156
|
+
*
|
|
157
|
+
* App-attribution vocabulary for provider requests.
|
|
158
|
+
* @module @hydraharness/harness-llm/attribution
|
|
159
|
+
*/
|
|
160
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
161
|
+
//#endregion
|
|
162
|
+
//#region ../../llm/llm/src/never.ts
|
|
163
|
+
/**
|
|
164
|
+
* Exhaustiveness helper for closed core unions. Use {@link assertNever} at the default branch so a
|
|
165
|
+
* new variant fails compilation at every required handler. Do not use it for declaration-merged
|
|
166
|
+
* unions such as session events or content blocks: handle known variants and explicitly fall
|
|
167
|
+
* through because plugins may add valid unknown cases.
|
|
168
|
+
* @module @hydraharness/harness-llm/never
|
|
169
|
+
*/
|
|
170
|
+
/**
|
|
171
|
+
* Mark an unreachable closed-union branch. A newly unhandled typed variant fails at the call site;
|
|
172
|
+
* a value that escaped its type throws with diagnostics at runtime.
|
|
173
|
+
* @param value - the impossible value; typed `never` so an unhandled variant fails compilation at the call site.
|
|
174
|
+
* @param context - optional label (e.g. the switch site) prefixed into the throw message.
|
|
175
|
+
* @returns never — it always throws, with the offending value JSON-rendered in the message.
|
|
176
|
+
*/
|
|
177
|
+
function assertNever(value, context) {
|
|
178
|
+
const rendered = JSON.stringify(value) ?? String(value);
|
|
179
|
+
throw new Error(`unreachable variant${context ? ` in ${context}` : ""}: ${rendered}`);
|
|
180
|
+
}
|
|
181
|
+
new AsyncLocalStorage();
|
|
182
|
+
//#endregion
|
|
183
|
+
//#region lib/types/request-zone.js
|
|
184
|
+
/** Browser-zone derivation and model-facing policy text for one open request turn. */
|
|
185
|
+
const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/;
|
|
186
|
+
/** Read and validate a Host-canonicalized browser zone from one ordinary user-rpc message. */
|
|
187
|
+
function browserTimeZone(message) {
|
|
188
|
+
const source = message.source;
|
|
189
|
+
const value = source.kind === "user" && "rpcId" in source && typeof source.rpcId === "string" && "clientTimeZone" in source && typeof source.clientTimeZone === "string" ? source.clientTimeZone : void 0;
|
|
190
|
+
if (value === void 0) return void 0;
|
|
191
|
+
if (value !== "UTC" && !IANA_TIME_ZONE.test(value)) throw new TypeError(`browser time zone must be canonical UTC or IANA Area/Location: ${JSON.stringify(value)}`);
|
|
192
|
+
let canonical;
|
|
193
|
+
try {
|
|
194
|
+
canonical = new Intl.DateTimeFormat("en-US", { timeZone: value }).resolvedOptions().timeZone;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
throw new TypeError(`browser time zone is unsupported: ${JSON.stringify(value)}`, { cause: error });
|
|
197
|
+
}
|
|
198
|
+
if (canonical !== value) throw new TypeError(`browser time zone must be canonical: ${JSON.stringify(value)}`);
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Derive the unique, mixed, or missing browser zone for one open turn.
|
|
203
|
+
* @param messages - Entered and proposed user messages belonging to the turn.
|
|
204
|
+
* @returns Sorted, duplicate-free browser-zone facts.
|
|
205
|
+
* @throws TypeError when a user-rpc source carries an invalid or noncanonical zone.
|
|
206
|
+
*/
|
|
207
|
+
function deriveBrowserTimeZoneContext(messages) {
|
|
208
|
+
const timeZones = [...new Set(messages.flatMap((message) => {
|
|
209
|
+
const timeZone = browserTimeZone(message);
|
|
210
|
+
return timeZone === void 0 ? [] : [timeZone];
|
|
211
|
+
}))].sort();
|
|
212
|
+
const [timeZone, ...remaining] = timeZones;
|
|
213
|
+
if (timeZone === void 0) return { kind: "missing" };
|
|
214
|
+
if (remaining.length === 0) return {
|
|
215
|
+
kind: "resolved",
|
|
216
|
+
timeZone
|
|
217
|
+
};
|
|
218
|
+
return {
|
|
219
|
+
kind: "mixed",
|
|
220
|
+
timeZones
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Render the model instruction for one browser-zone context.
|
|
225
|
+
* @param context - Browser-zone facts for the open turn.
|
|
226
|
+
* @returns One durable policy line.
|
|
227
|
+
*/
|
|
228
|
+
function renderBrowserTimeZoneContext(context) {
|
|
229
|
+
switch (context.kind) {
|
|
230
|
+
case "resolved": return `Browser time zone for this request: ${context.timeZone}. Interpret otherwise-unqualified dates and times in this zone.`;
|
|
231
|
+
case "mixed": return `Browser time zone for this request: mixed ${JSON.stringify(context.timeZones)}. Ask the user to clarify otherwise-unqualified dates and times.`;
|
|
232
|
+
case "missing": return "Browser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.";
|
|
233
|
+
/* v8 ignore next 2 -- the closed BrowserTimeZoneContext union is exhausted above. */
|
|
234
|
+
default: return assertNever(context, "BrowserTimeZoneContext");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
//#endregion
|
|
238
|
+
//#region lib/types/timestamp.js
|
|
239
|
+
/** ISO-shaped time-context timestamp formatting shared by production and replay validation. */
|
|
240
|
+
/**
|
|
241
|
+
* Create the exact formatter used by durable time-context readings.
|
|
242
|
+
* @param timeZone - Explicit display zone, or `undefined` for the process fallback.
|
|
243
|
+
* @returns A formatter with stable numeric local fields and long numeric offset.
|
|
244
|
+
*/
|
|
245
|
+
function createTimestampFormatter(timeZone) {
|
|
246
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
247
|
+
...timeZone === void 0 ? {} : { timeZone },
|
|
248
|
+
year: "numeric",
|
|
249
|
+
month: "2-digit",
|
|
250
|
+
day: "2-digit",
|
|
251
|
+
hour: "2-digit",
|
|
252
|
+
minute: "2-digit",
|
|
253
|
+
second: "2-digit",
|
|
254
|
+
hourCycle: "h23",
|
|
255
|
+
timeZoneName: "longOffset"
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone.
|
|
260
|
+
* @param now - Epoch milliseconds to display.
|
|
261
|
+
* @param formatter - Formatter created for `timeZone`.
|
|
262
|
+
* @param timeZone - Canonical zone label carried in brackets.
|
|
263
|
+
* @returns The durable timestamp text.
|
|
264
|
+
*/
|
|
265
|
+
function formatTimestamp(now, formatter, timeZone) {
|
|
266
|
+
const parts = Object.fromEntries(formatter.formatToParts(now).map((part) => [part.type, part.value]));
|
|
267
|
+
const offset = parts.timeZoneName.replace(/^GMT$/, "GMT+00:00").slice(3);
|
|
268
|
+
return `${parts["year"]}-${parts["month"]}-${parts["day"]}T${parts["hour"]}:${parts["minute"]}:${parts["second"]}${offset}[${timeZone}]`;
|
|
269
|
+
}
|
|
270
|
+
//#endregion
|
|
271
|
+
//#region lib/types/index.js
|
|
272
|
+
/**
|
|
273
|
+
* Opt-in request clock context. Eligible steps add durable,
|
|
274
|
+
* source-attributed time readings to the request history.
|
|
275
|
+
*
|
|
276
|
+
* @module @hydraharness/harness-time-context
|
|
277
|
+
*/
|
|
278
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
279
|
+
const name = "time-context";
|
|
280
|
+
/** The agent registry that owns pre-step processing. */
|
|
281
|
+
const inject = ["agents"];
|
|
282
|
+
/** Schemastery validation for {@link Config}. */
|
|
283
|
+
const Config = z.object({
|
|
284
|
+
timeZone: z.string(),
|
|
285
|
+
refreshIntervalMs: z.number()
|
|
286
|
+
});
|
|
287
|
+
/** Format a non-negative elapsed millisecond count as compact whole-second units. */
|
|
288
|
+
function formatDuration(elapsedMs) {
|
|
289
|
+
let seconds = Math.floor(Math.max(0, elapsedMs) / 1e3);
|
|
290
|
+
const days = Math.floor(seconds / 86400);
|
|
291
|
+
seconds %= 86400;
|
|
292
|
+
const hours = Math.floor(seconds / 3600);
|
|
293
|
+
seconds %= 3600;
|
|
294
|
+
const minutes = Math.floor(seconds / 60);
|
|
295
|
+
seconds %= 60;
|
|
296
|
+
const parts = [];
|
|
297
|
+
if (days > 0) parts.push(`${days}d`);
|
|
298
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
299
|
+
if (minutes > 0) parts.push(`${minutes}m`);
|
|
300
|
+
parts.push(`${seconds}s`);
|
|
301
|
+
return parts.join(" ");
|
|
302
|
+
}
|
|
303
|
+
/** Find the latest model-visible event, excluding this plugin's pending append. */
|
|
304
|
+
function precedingMessageTime(agent) {
|
|
305
|
+
for (const event of [...agent.session.activeEvents].reverse()) switch (event.type) {
|
|
306
|
+
case "user/message":
|
|
307
|
+
case "assistant/message":
|
|
308
|
+
case "tool/result": return event.time;
|
|
309
|
+
default: break;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/** Find the preceding time-context event within the open turn. */
|
|
313
|
+
function precedingStepContextTime(agent, turn) {
|
|
314
|
+
for (const event of [...agent.session.activeEvents].reverse()) {
|
|
315
|
+
if (event.type === "turn/start" && event.data.turn === turn) return void 0;
|
|
316
|
+
if (event.type === "user/message" && event.data.source.kind === "plugin" && event.data.source.plugin === "time-context") return event.time;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/** Find this plugin's latest durable injection, including a shadowed surface event. */
|
|
320
|
+
function latestInjectionTime(agent) {
|
|
321
|
+
for (const event of [...agent.session.activeEvents].reverse()) if (event.type === "user/message" && event.data.source.kind === "plugin" && event.data.source.plugin === "time-context") return event.time;
|
|
322
|
+
}
|
|
323
|
+
/** Collect already-entered and proposed user messages belonging to one open turn. */
|
|
324
|
+
function requestMessages(agent, turn, proposed) {
|
|
325
|
+
const start = agent.session.activeEvents.findLastIndex((event) => event.type === "turn/start" && event.data.turn === turn);
|
|
326
|
+
return [...start < 0 ? [] : agent.session.activeEvents.slice(start + 1).flatMap((event) => event.type === "user/message" ? [event.data] : []), ...proposed];
|
|
327
|
+
}
|
|
328
|
+
function renderText(now, turn, step, previous, formatter, timeZone, browserContext) {
|
|
329
|
+
const elapsed = previous === void 0 ? "unavailable" : formatDuration(now - previous);
|
|
330
|
+
const baseline = step === 1 ? "model-visible message" : "step context";
|
|
331
|
+
const browserText = renderBrowserTimeZoneContext(browserContext);
|
|
332
|
+
return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\n${browserText}\nElapsed since the preceding ${baseline}: ${elapsed}.`;
|
|
333
|
+
}
|
|
334
|
+
/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
|
|
335
|
+
function validateRefreshInterval(refreshIntervalMs) {
|
|
336
|
+
if (refreshIntervalMs !== void 0 && (!Number.isSafeInteger(refreshIntervalMs) || refreshIntervalMs < 0)) throw new TypeError(`time-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`);
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Register a prepended pre-step listener for the lifetime of `ctx`.
|
|
340
|
+
* @param ctx - plugin context; the listener is disposed with it.
|
|
341
|
+
* @param config - time zone and durable refresh scheduling configuration.
|
|
342
|
+
* @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved.
|
|
343
|
+
*/
|
|
344
|
+
function apply(ctx, config) {
|
|
345
|
+
const timeZone = config.timeZone;
|
|
346
|
+
const refreshIntervalMs = config.refreshIntervalMs;
|
|
347
|
+
validateRefreshInterval(refreshIntervalMs);
|
|
348
|
+
let fallbackFormatter;
|
|
349
|
+
try {
|
|
350
|
+
fallbackFormatter = createTimestampFormatter(timeZone);
|
|
351
|
+
} catch (error) {
|
|
352
|
+
const message = timeZone === void 0 ? "time-context: failed to resolve the system time zone" : `time-context: invalid IANA timeZone ${JSON.stringify(timeZone)}`;
|
|
353
|
+
throw new Error(message, { cause: error });
|
|
354
|
+
}
|
|
355
|
+
const fallbackTimeZone = fallbackFormatter.resolvedOptions().timeZone;
|
|
356
|
+
const formatters = new Map([[fallbackTimeZone, fallbackFormatter]]);
|
|
357
|
+
/** Resolve and cache one request-local timestamp formatter. */
|
|
358
|
+
const formatterFor = (selectedTimeZone) => {
|
|
359
|
+
const existing = formatters.get(selectedTimeZone);
|
|
360
|
+
if (existing !== void 0) return existing;
|
|
361
|
+
const created = createTimestampFormatter(selectedTimeZone);
|
|
362
|
+
formatters.set(selectedTimeZone, created);
|
|
363
|
+
return created;
|
|
364
|
+
};
|
|
365
|
+
ctx.on("agent/pre-step", async ({ agent, turn, step, signal }, next) => {
|
|
366
|
+
const decision = await next();
|
|
367
|
+
if (decision.kind === "reject" || signal.aborted) return decision;
|
|
368
|
+
const now = Date.now();
|
|
369
|
+
if (refreshIntervalMs !== void 0 && refreshIntervalMs > 0) {
|
|
370
|
+
const lastInjection = latestInjectionTime(agent);
|
|
371
|
+
if (lastInjection !== void 0 && now >= lastInjection && now - lastInjection < refreshIntervalMs) return decision;
|
|
372
|
+
}
|
|
373
|
+
const previous = step === 1 ? precedingMessageTime(agent) : precedingStepContextTime(agent, turn);
|
|
374
|
+
const browser = deriveBrowserTimeZoneContext(requestMessages(agent, turn, decision.messages));
|
|
375
|
+
const selectedTimeZone = browser.kind === "resolved" ? browser.timeZone : fallbackTimeZone;
|
|
376
|
+
const text = renderText(now, turn, step, previous, formatterFor(selectedTimeZone), selectedTimeZone, browser);
|
|
377
|
+
return {
|
|
378
|
+
kind: "enter",
|
|
379
|
+
messages: [...decision.messages, createUserMessage({
|
|
380
|
+
content: [{
|
|
381
|
+
type: "text",
|
|
382
|
+
text
|
|
383
|
+
}],
|
|
384
|
+
source: {
|
|
385
|
+
kind: "plugin",
|
|
386
|
+
plugin: name,
|
|
387
|
+
form: "snapshot",
|
|
388
|
+
sections: [{
|
|
389
|
+
name,
|
|
390
|
+
text
|
|
391
|
+
}]
|
|
392
|
+
}
|
|
393
|
+
})]
|
|
394
|
+
};
|
|
395
|
+
}, { prepend: true });
|
|
396
|
+
}
|
|
397
|
+
//#endregion
|
|
398
|
+
export { Config, apply, inject, name };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { SessionVersionIndex } from "@hydraharness/harness-session";
|
|
3
|
+
import "@hydraharness/cordis";
|
|
4
|
+
import z from "@hydraharness/schemastery";
|
|
5
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
6
|
+
//#region ../../util/timeout/src/index.ts
|
|
7
|
+
/** Largest delay Node schedules without clamping it to one millisecond. */
|
|
8
|
+
const MAX_TIMER_DELAY_MS = 2147483647;
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region ../../llm/llm/src/error.ts
|
|
11
|
+
/**
|
|
12
|
+
* Canonical provider-neutral code for a response that completed normally but
|
|
13
|
+
* carried no content blocks at all. Providers occasionally emit a degenerate
|
|
14
|
+
* completion (a terminal stop with zero output); adapters classify it as this
|
|
15
|
+
* failure instead of yielding an empty assistant message, because an empty
|
|
16
|
+
* message silently ends the turn with nothing for the user or the loop to act
|
|
17
|
+
* on. The attempt produced nothing durable, so retry policy treats it as safe
|
|
18
|
+
* to repeat.
|
|
19
|
+
*/
|
|
20
|
+
const EMPTY_RESPONSE_CODE = "EMPTY_RESPONSE";
|
|
21
|
+
new RegExp(String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]` + String.raw`(?:exceed(?:ed|s)?|overflow(?:ed)?|limit[\s_-]exceeded)(?:$|[^a-z0-9])`, "i");
|
|
22
|
+
new RegExp(String.raw`\b(?:request|prompt|input|messages?)\s+(?:is\s+|are\s+)?` + String.raw`too\s+(?:large|long)\s+for\s+(?:(?:this|the)\s+)?` + String.raw`(?:model(?:'s)?\s+)?context(?:\s+window)?\b`, "i");
|
|
23
|
+
new RegExp(String.raw`\b(?:input|prompt|request|messages?)\b.{0,40}` + String.raw`\b(?:exceed(?:s|ed)?|overflows?|is\s+larger\s+than)\b.{0,40}` + String.raw`\b(?:the\s+)?(?:model(?:'s)?\s+)?context(?:\s+(?:length|window))?\b`, "i");
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region ../../llm/llm/src/retry-policy.ts
|
|
26
|
+
/**
|
|
27
|
+
* Provider-owned request-retry policy configuration and resolution.
|
|
28
|
+
*
|
|
29
|
+
* Adapters expose one resolved policy per registered provider route; the
|
|
30
|
+
* optional @hydraharness/harness-llm-retry plugin executes it on the agent's failed-step extension point.
|
|
31
|
+
*
|
|
32
|
+
* @module @hydraharness/harness-llm/retry-policy
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_MAX_RETRIES = 5;
|
|
35
|
+
const DEFAULT_INITIAL_DELAY_MS = 500;
|
|
36
|
+
const DEFAULT_MAX_DELAY_MS = 1e4;
|
|
37
|
+
const DEFAULT_JITTER_RATIO = .1;
|
|
38
|
+
const DEFAULT_RETRYABLE_CODES = Object.freeze([
|
|
39
|
+
EMPTY_RESPONSE_CODE,
|
|
40
|
+
"RATE_LIMIT",
|
|
41
|
+
"SERVER",
|
|
42
|
+
"TIMEOUT",
|
|
43
|
+
"TRANSPORT"
|
|
44
|
+
]);
|
|
45
|
+
const backoffSchema = z.object({
|
|
46
|
+
initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS),
|
|
47
|
+
maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS),
|
|
48
|
+
jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO)
|
|
49
|
+
});
|
|
50
|
+
const normalPolicySchema = z.object({
|
|
51
|
+
mode: z.const("normal").required(),
|
|
52
|
+
maxRetries: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RETRIES),
|
|
53
|
+
retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]),
|
|
54
|
+
backoff: backoffSchema
|
|
55
|
+
});
|
|
56
|
+
const alwaysPolicySchema = z.object({
|
|
57
|
+
mode: z.const("always").required(),
|
|
58
|
+
backoff: backoffSchema
|
|
59
|
+
});
|
|
60
|
+
z.union([normalPolicySchema, alwaysPolicySchema]);
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region ../../llm/llm/src/attribution.ts
|
|
63
|
+
/**
|
|
64
|
+
* Centralize the non-secret product identity every provider request sends as `User-Agent`, keeping
|
|
65
|
+
* adapters from drifting. See
|
|
66
|
+
* `.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md`.
|
|
67
|
+
*
|
|
68
|
+
* App-attribution vocabulary for provider requests.
|
|
69
|
+
* @module @hydraharness/harness-llm/attribution
|
|
70
|
+
*/
|
|
71
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region ../../llm/llm/src/never.ts
|
|
74
|
+
/**
|
|
75
|
+
* Exhaustiveness helper for closed core unions. Use {@link assertNever} at the default branch so a
|
|
76
|
+
* new variant fails compilation at every required handler. Do not use it for declaration-merged
|
|
77
|
+
* unions such as session events or content blocks: handle known variants and explicitly fall
|
|
78
|
+
* through because plugins may add valid unknown cases.
|
|
79
|
+
* @module @hydraharness/harness-llm/never
|
|
80
|
+
*/
|
|
81
|
+
/**
|
|
82
|
+
* Mark an unreachable closed-union branch. A newly unhandled typed variant fails at the call site;
|
|
83
|
+
* a value that escaped its type throws with diagnostics at runtime.
|
|
84
|
+
* @param value - the impossible value; typed `never` so an unhandled variant fails compilation at the call site.
|
|
85
|
+
* @param context - optional label (e.g. the switch site) prefixed into the throw message.
|
|
86
|
+
* @returns never — it always throws, with the offending value JSON-rendered in the message.
|
|
87
|
+
*/
|
|
88
|
+
function assertNever(value, context) {
|
|
89
|
+
const rendered = JSON.stringify(value) ?? String(value);
|
|
90
|
+
throw new Error(`unreachable variant${context ? ` in ${context}` : ""}: ${rendered}`);
|
|
91
|
+
}
|
|
92
|
+
new AsyncLocalStorage();
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region lib/types/request-zone.js
|
|
95
|
+
/** Browser-zone derivation and model-facing policy text for one open request turn. */
|
|
96
|
+
const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/;
|
|
97
|
+
/** Read and validate a Host-canonicalized browser zone from one ordinary user-rpc message. */
|
|
98
|
+
function browserTimeZone(message) {
|
|
99
|
+
const source = message.source;
|
|
100
|
+
const value = source.kind === "user" && "rpcId" in source && typeof source.rpcId === "string" && "clientTimeZone" in source && typeof source.clientTimeZone === "string" ? source.clientTimeZone : void 0;
|
|
101
|
+
if (value === void 0) return void 0;
|
|
102
|
+
if (value !== "UTC" && !IANA_TIME_ZONE.test(value)) throw new TypeError(`browser time zone must be canonical UTC or IANA Area/Location: ${JSON.stringify(value)}`);
|
|
103
|
+
let canonical;
|
|
104
|
+
try {
|
|
105
|
+
canonical = new Intl.DateTimeFormat("en-US", { timeZone: value }).resolvedOptions().timeZone;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw new TypeError(`browser time zone is unsupported: ${JSON.stringify(value)}`, { cause: error });
|
|
108
|
+
}
|
|
109
|
+
if (canonical !== value) throw new TypeError(`browser time zone must be canonical: ${JSON.stringify(value)}`);
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Derive the unique, mixed, or missing browser zone for one open turn.
|
|
114
|
+
* @param messages - Entered and proposed user messages belonging to the turn.
|
|
115
|
+
* @returns Sorted, duplicate-free browser-zone facts.
|
|
116
|
+
* @throws TypeError when a user-rpc source carries an invalid or noncanonical zone.
|
|
117
|
+
*/
|
|
118
|
+
function deriveBrowserTimeZoneContext(messages) {
|
|
119
|
+
const timeZones = [...new Set(messages.flatMap((message) => {
|
|
120
|
+
const timeZone = browserTimeZone(message);
|
|
121
|
+
return timeZone === void 0 ? [] : [timeZone];
|
|
122
|
+
}))].sort();
|
|
123
|
+
const [timeZone, ...remaining] = timeZones;
|
|
124
|
+
if (timeZone === void 0) return { kind: "missing" };
|
|
125
|
+
if (remaining.length === 0) return {
|
|
126
|
+
kind: "resolved",
|
|
127
|
+
timeZone
|
|
128
|
+
};
|
|
129
|
+
return {
|
|
130
|
+
kind: "mixed",
|
|
131
|
+
timeZones
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Render the model instruction for one browser-zone context.
|
|
136
|
+
* @param context - Browser-zone facts for the open turn.
|
|
137
|
+
* @returns One durable policy line.
|
|
138
|
+
*/
|
|
139
|
+
function renderBrowserTimeZoneContext(context) {
|
|
140
|
+
switch (context.kind) {
|
|
141
|
+
case "resolved": return `Browser time zone for this request: ${context.timeZone}. Interpret otherwise-unqualified dates and times in this zone.`;
|
|
142
|
+
case "mixed": return `Browser time zone for this request: mixed ${JSON.stringify(context.timeZones)}. Ask the user to clarify otherwise-unqualified dates and times.`;
|
|
143
|
+
case "missing": return "Browser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.";
|
|
144
|
+
/* v8 ignore next 2 -- the closed BrowserTimeZoneContext union is exhausted above. */
|
|
145
|
+
default: return assertNever(context, "BrowserTimeZoneContext");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region lib/types/timestamp.js
|
|
150
|
+
/** ISO-shaped time-context timestamp formatting shared by production and replay validation. */
|
|
151
|
+
/**
|
|
152
|
+
* Create the exact formatter used by durable time-context readings.
|
|
153
|
+
* @param timeZone - Explicit display zone, or `undefined` for the process fallback.
|
|
154
|
+
* @returns A formatter with stable numeric local fields and long numeric offset.
|
|
155
|
+
*/
|
|
156
|
+
function createTimestampFormatter(timeZone) {
|
|
157
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
158
|
+
...timeZone === void 0 ? {} : { timeZone },
|
|
159
|
+
year: "numeric",
|
|
160
|
+
month: "2-digit",
|
|
161
|
+
day: "2-digit",
|
|
162
|
+
hour: "2-digit",
|
|
163
|
+
minute: "2-digit",
|
|
164
|
+
second: "2-digit",
|
|
165
|
+
hourCycle: "h23",
|
|
166
|
+
timeZoneName: "longOffset"
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone.
|
|
171
|
+
* @param now - Epoch milliseconds to display.
|
|
172
|
+
* @param formatter - Formatter created for `timeZone`.
|
|
173
|
+
* @param timeZone - Canonical zone label carried in brackets.
|
|
174
|
+
* @returns The durable timestamp text.
|
|
175
|
+
*/
|
|
176
|
+
function formatTimestamp(now, formatter, timeZone) {
|
|
177
|
+
const parts = Object.fromEntries(formatter.formatToParts(now).map((part) => [part.type, part.value]));
|
|
178
|
+
const offset = parts.timeZoneName.replace(/^GMT$/, "GMT+00:00").slice(3);
|
|
179
|
+
return `${parts["year"]}-${parts["month"]}-${parts["day"]}T${parts["hour"]}:${parts["minute"]}:${parts["second"]}${offset}[${timeZone}]`;
|
|
180
|
+
}
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region lib/types/invariant.js
|
|
183
|
+
/** Package-owned durable clock-context invariants. @module @hydraharness/harness-time-context/invariant */
|
|
184
|
+
const PACKAGE_NAME = "@hydraharness/harness-time-context";
|
|
185
|
+
const SOURCE_NAME = "time-context";
|
|
186
|
+
const READING = /* @__PURE__ */ new RegExp("^Time sampled while preparing turn (\\d+), step (\\d+): (\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:Z|[+-]\\d{2}:\\d{2})\\[[^\\]]+\\])\\n(Browser time zone for this request: .+)\\nElapsed since the preceding (model-visible message|step context): (?:unavailable|(?:(?:\\d+d )?(?:\\d+h )?(?:\\d+m )?\\d+s))\\.$");
|
|
187
|
+
/** Cordis companion plugin name. */
|
|
188
|
+
const name = "time-context-invariant";
|
|
189
|
+
/** Service required before the companion can reserve package ownership. */
|
|
190
|
+
const inject = ["invariants"];
|
|
191
|
+
/** Derive the open step boundary at which a time-context reading may append. */
|
|
192
|
+
function preparationPosition(history, fail) {
|
|
193
|
+
let openTurn;
|
|
194
|
+
let openStep;
|
|
195
|
+
let requestStarted = false;
|
|
196
|
+
for (const event of history) switch (event.type) {
|
|
197
|
+
case "turn/start":
|
|
198
|
+
openTurn = event.data.turn;
|
|
199
|
+
openStep = void 0;
|
|
200
|
+
requestStarted = false;
|
|
201
|
+
break;
|
|
202
|
+
case "step/start":
|
|
203
|
+
openStep = event.data.step;
|
|
204
|
+
requestStarted = false;
|
|
205
|
+
break;
|
|
206
|
+
case "request/header":
|
|
207
|
+
requestStarted = true;
|
|
208
|
+
break;
|
|
209
|
+
case "step/end":
|
|
210
|
+
openStep = void 0;
|
|
211
|
+
requestStarted = false;
|
|
212
|
+
break;
|
|
213
|
+
case "turn/end":
|
|
214
|
+
openTurn = void 0;
|
|
215
|
+
openStep = void 0;
|
|
216
|
+
requestStarted = false;
|
|
217
|
+
break;
|
|
218
|
+
default: break;
|
|
219
|
+
}
|
|
220
|
+
if (openTurn === void 0) fail("time-context reading must be appended inside an open turn");
|
|
221
|
+
if (openStep === void 0) fail("time-context reading must follow step/start");
|
|
222
|
+
if (requestStarted) fail("time-context reading must precede request/header");
|
|
223
|
+
return {
|
|
224
|
+
turn: openTurn,
|
|
225
|
+
step: openStep
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/** Collect the entered user messages belonging to one open turn. */
|
|
229
|
+
function requestMessages(history, turn) {
|
|
230
|
+
const start = history.findLastIndex((event) => event.type === "turn/start" && event.data.turn === turn);
|
|
231
|
+
return history.slice(start + 1).flatMap((event) => event.type === "user/message" ? [event.data] : []);
|
|
232
|
+
}
|
|
233
|
+
/** Validate one plugin-attributed time reading against its session position and timestamp. */
|
|
234
|
+
function validateReading(history, event, fail) {
|
|
235
|
+
const blockValue = event.data.content[0];
|
|
236
|
+
const block = typeof blockValue === "object" && blockValue !== null ? blockValue : void 0;
|
|
237
|
+
const blockText = block?.text;
|
|
238
|
+
if (event.data.content.length !== 1 || block === void 0 || Object.keys(block).length !== 2 || block.type !== "text" || typeof blockText !== "string") fail("time-context messages must contain exactly one text block");
|
|
239
|
+
const match = READING.exec(blockText);
|
|
240
|
+
if (match === null) fail("time-context message does not match the durable reading format");
|
|
241
|
+
const turn = Number(match[1]);
|
|
242
|
+
const step = Number(match[2]);
|
|
243
|
+
if (!Number.isSafeInteger(turn) || turn < 1 || !Number.isSafeInteger(step) || step < 1) fail("time-context turn and step must be positive safe integers");
|
|
244
|
+
const expected = preparationPosition(history, fail);
|
|
245
|
+
if (turn !== expected.turn || step !== expected.step) fail(`time-context reading names turn ${turn}/step ${step}, expected turn ${expected.turn}/step ${expected.step}`);
|
|
246
|
+
const source = event.data.source;
|
|
247
|
+
/* v8 ignore next 2 -- replay and dispatch callers select this exact package-owned source before validation. */
|
|
248
|
+
if (source.kind !== "plugin" || source.plugin !== SOURCE_NAME) fail("time-context source must retain package ownership");
|
|
249
|
+
const sections = "sections" in source ? source.sections : void 0;
|
|
250
|
+
const sectionValue = Array.isArray(sections) ? sections[0] : void 0;
|
|
251
|
+
const section = typeof sectionValue === "object" && sectionValue !== null ? sectionValue : void 0;
|
|
252
|
+
if (Object.keys(source).length !== 4 || source.form !== "snapshot" || !Array.isArray(sections) || sections.length !== 1 || section === void 0 || Object.keys(section).length !== 2 || section.name !== SOURCE_NAME || section.text !== blockText) fail("time-context source must carry only the exact snapshot text, not request authority");
|
|
253
|
+
const renderedBrowserContext = match[4];
|
|
254
|
+
const browserContext = deriveBrowserTimeZoneContext(requestMessages(history, turn));
|
|
255
|
+
if (renderedBrowserContext !== renderBrowserTimeZoneContext(browserContext)) fail("time-context browser-zone text does not match current-turn user messages");
|
|
256
|
+
const baseline = match[5];
|
|
257
|
+
if (step === 1 !== (baseline === "model-visible message")) fail(`time-context step ${step} uses the wrong elapsed-time baseline ${JSON.stringify(baseline)}`);
|
|
258
|
+
const rendered = match[3];
|
|
259
|
+
/* v8 ignore next -- the preceding fixed regexp always supplies capture group three. */
|
|
260
|
+
if (rendered === void 0) fail("time-context reading omitted its rendered timestamp");
|
|
261
|
+
const renderedTime = Date.parse(rendered.replace(/\[[^\]]+\]$/, ""));
|
|
262
|
+
if (!Number.isFinite(renderedTime) || !Number.isSafeInteger(event.time) || event.time < renderedTime) fail("time-context rendered timestamp must parse and not postdate its durable event");
|
|
263
|
+
if (browserContext.kind === "resolved") {
|
|
264
|
+
let expectedTimestamp;
|
|
265
|
+
try {
|
|
266
|
+
expectedTimestamp = formatTimestamp(renderedTime, createTimestampFormatter(browserContext.timeZone), browserContext.timeZone);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
fail(`time-context browser zone cannot format its durable timestamp: ${String(error)}`);
|
|
269
|
+
}
|
|
270
|
+
if (rendered !== expectedTimestamp) fail("time-context rendered timestamp does not match the unique browser zone");
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** Validate all package-owned readings already present in one session. */
|
|
274
|
+
function validateSession(session, fail) {
|
|
275
|
+
const versions = new SessionVersionIndex();
|
|
276
|
+
for (const event of session.events) {
|
|
277
|
+
versions.append(event);
|
|
278
|
+
if (event.type !== "user/message" || event.data.source.kind !== "plugin" || event.data.source.plugin !== SOURCE_NAME) continue;
|
|
279
|
+
validateReading(versions.events().filter((entry) => entry.seq < event.seq), event, fail);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/** Install validation for loaded and newly appended context readings. */
|
|
283
|
+
const install = Object.assign((ctx, fail) => {
|
|
284
|
+
for (const session of ctx.sessions.list()) validateSession(session, fail);
|
|
285
|
+
ctx.on("session/created", (session) => {
|
|
286
|
+
validateSession(session, fail);
|
|
287
|
+
}, { global: true });
|
|
288
|
+
ctx.on("internal/dispatch", (_mode, eventName, args) => {
|
|
289
|
+
if (eventName !== "session/event") return;
|
|
290
|
+
const [session, event] = args;
|
|
291
|
+
if (event.type !== "user/message" || event.data.source.kind !== "plugin" || event.data.source.plugin !== SOURCE_NAME) return;
|
|
292
|
+
validateReading(session.activeEvents, event, fail);
|
|
293
|
+
}, { global: true });
|
|
294
|
+
}, { inject: ["sessions"] });
|
|
295
|
+
/**
|
|
296
|
+
* Register the time-context invariant companion.
|
|
297
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
298
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
299
|
+
*/
|
|
300
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
301
|
+
//#endregion
|
|
302
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in request clock context. Eligible steps add durable,
|
|
3
|
+
* source-attributed time readings to the request history.
|
|
4
|
+
*
|
|
5
|
+
* @module @hydraharness/harness-time-context
|
|
6
|
+
*/
|
|
7
|
+
import type { Context } from '@hydraharness/cordis';
|
|
8
|
+
import z from '@hydraharness/schemastery';
|
|
9
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
10
|
+
export declare const name = "time-context";
|
|
11
|
+
/** The agent registry that owns pre-step processing. */
|
|
12
|
+
export declare const inject: string[];
|
|
13
|
+
/** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
|
|
14
|
+
export interface Config {
|
|
15
|
+
/** Fallback display zone when the open turn has no unique browser zone. Omit to use the process zone. */
|
|
16
|
+
timeZone?: string;
|
|
17
|
+
/** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject at every eligible step. */
|
|
18
|
+
refreshIntervalMs?: number;
|
|
19
|
+
}
|
|
20
|
+
/** Schemastery validation for {@link Config}. */
|
|
21
|
+
export declare const Config: z<Config>;
|
|
22
|
+
/**
|
|
23
|
+
* Register a prepended pre-step listener for the lifetime of `ctx`.
|
|
24
|
+
* @param ctx - plugin context; the listener is disposed with it.
|
|
25
|
+
* @param config - time zone and durable refresh scheduling configuration.
|
|
26
|
+
* @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved.
|
|
27
|
+
*/
|
|
28
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
29
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Package-owned durable clock-context invariants. @module @hydraharness/harness-time-context/invariant */
|
|
2
|
+
import type { Context } from '@hydraharness/cordis';
|
|
3
|
+
/** Cordis companion plugin name. */
|
|
4
|
+
export declare const name = "time-context-invariant";
|
|
5
|
+
/** Service required before the companion can reserve package ownership. */
|
|
6
|
+
export declare const inject: string[];
|
|
7
|
+
/**
|
|
8
|
+
* Register the time-context invariant companion.
|
|
9
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
10
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
11
|
+
*/
|
|
12
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
13
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Browser-zone derivation and model-facing policy text for one open request turn. */
|
|
2
|
+
import type { UserMessage } from '@hydraharness/harness-llm';
|
|
3
|
+
/** Browser-zone facts derived from user-rpc messages in one open turn. */
|
|
4
|
+
export type BrowserTimeZoneContext = {
|
|
5
|
+
readonly kind: 'resolved';
|
|
6
|
+
readonly timeZone: string;
|
|
7
|
+
} | {
|
|
8
|
+
readonly kind: 'mixed';
|
|
9
|
+
readonly timeZones: readonly string[];
|
|
10
|
+
} | {
|
|
11
|
+
readonly kind: 'missing';
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Derive the unique, mixed, or missing browser zone for one open turn.
|
|
15
|
+
* @param messages - Entered and proposed user messages belonging to the turn.
|
|
16
|
+
* @returns Sorted, duplicate-free browser-zone facts.
|
|
17
|
+
* @throws TypeError when a user-rpc source carries an invalid or noncanonical zone.
|
|
18
|
+
*/
|
|
19
|
+
export declare function deriveBrowserTimeZoneContext(messages: readonly UserMessage[]): BrowserTimeZoneContext;
|
|
20
|
+
/**
|
|
21
|
+
* Render the model instruction for one browser-zone context.
|
|
22
|
+
* @param context - Browser-zone facts for the open turn.
|
|
23
|
+
* @returns One durable policy line.
|
|
24
|
+
*/
|
|
25
|
+
export declare function renderBrowserTimeZoneContext(context: BrowserTimeZoneContext): string;
|
|
26
|
+
//# sourceMappingURL=request-zone.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** ISO-shaped time-context timestamp formatting shared by production and replay validation. */
|
|
2
|
+
/**
|
|
3
|
+
* Create the exact formatter used by durable time-context readings.
|
|
4
|
+
* @param timeZone - Explicit display zone, or `undefined` for the process fallback.
|
|
5
|
+
* @returns A formatter with stable numeric local fields and long numeric offset.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createTimestampFormatter(timeZone?: string): Intl.DateTimeFormat;
|
|
8
|
+
/**
|
|
9
|
+
* Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone.
|
|
10
|
+
* @param now - Epoch milliseconds to display.
|
|
11
|
+
* @param formatter - Formatter created for `timeZone`.
|
|
12
|
+
* @param timeZone - Canonical zone label carried in brackets.
|
|
13
|
+
* @returns The durable timestamp text.
|
|
14
|
+
*/
|
|
15
|
+
export declare function formatTimestamp(now: number, formatter: Intl.DateTimeFormat, timeZone: string): string;
|
|
16
|
+
//# sourceMappingURL=timestamp.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hydraharness/harness-time-context",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"description": "Opt-in durable per-step context with the current time and elapsed time",
|
|
4
|
+
"version": "0.1.1-rc.7",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/MaiHongPhong1902/Hydra-Harness.git",
|
|
11
|
+
"directory": "packages/context/time-context"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./invariant": {
|
|
22
|
+
"types": "./lib/types/invariant.d.ts",
|
|
23
|
+
"default": "./lib/invariant.js"
|
|
24
|
+
},
|
|
25
|
+
"./src/*": "./src/*",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"lib/index.js",
|
|
30
|
+
"lib/invariant.js",
|
|
31
|
+
"lib/types/**/*.d.ts"
|
|
32
|
+
],
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@hydraharness/schemastery": "^3.18.2"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.7",
|
|
39
|
+
"@hydraharness/cordis": "^4.0.2",
|
|
40
|
+
"@hydraharness/harness-agent": "^0.1.1-rc.7",
|
|
41
|
+
"@hydraharness/harness-session": "^0.1.1-rc.7"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@hydraharness/harness-agent": "^0.1.1-rc.7",
|
|
45
|
+
"@hydraharness/harness-agent-loop": "^0.1.1-rc.7",
|
|
46
|
+
"@hydraharness/harness-agent-loop-testkit": "^0.1.1-rc.7",
|
|
47
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.7",
|
|
48
|
+
"@hydraharness/harness-llm": "^0.1.1-rc.7",
|
|
49
|
+
"@hydraharness/harness-session": "^0.1.1-rc.7",
|
|
50
|
+
"@hydraharness/harness-loader-smoke": "^0.1.1-rc.7",
|
|
51
|
+
"@hydraharness/harness-tools": "^0.1.1-rc.7",
|
|
52
|
+
"@hydraharness/cordis": "^4.0.2",
|
|
53
|
+
"@hydraharness/harness-system-prompt": "^0.1.1-rc.7"
|
|
54
|
+
}
|
|
6
55
|
}
|