@gt-fe/eap-runtime 0.0.35 → 0.0.37

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.
@@ -1172,6 +1172,7 @@ export class LangGraphAdapter {
1172
1172
  },
1173
1173
  correlationId: typeof state['correlationId'] === 'string' ? state['correlationId'] : undefined,
1174
1174
  taskId: typeof state['taskId'] === 'string' ? state['taskId'] : undefined,
1175
+ sessionId: typeof state['sessionId'] === 'string' ? state['sessionId'] : undefined,
1175
1176
  onListFailure: ({ toolset, error }) => {
1176
1177
  this.emitToolExecutionEvents({
1177
1178
  toolCallId: `mcp-list:${toolset}`,
@@ -270,6 +270,26 @@ function resolveMcpServer(manifest) {
270
270
  }
271
271
  return undefined;
272
272
  }
273
+ function mcpKnownToolNames(manifest) {
274
+ const tools = manifest.tools;
275
+ if (!Array.isArray(tools))
276
+ return [];
277
+ const names = [];
278
+ const seen = new Set();
279
+ for (const tool of tools) {
280
+ if (!tool || typeof tool !== 'object')
281
+ continue;
282
+ const name = tool.name;
283
+ if (typeof name !== 'string')
284
+ continue;
285
+ const trimmed = name.trim();
286
+ if (!trimmed || seen.has(trimmed))
287
+ continue;
288
+ seen.add(trimmed);
289
+ names.push(trimmed);
290
+ }
291
+ return names;
292
+ }
273
293
  /** 门户把同一次注册的「用户确认」写在每个 tools[] 上。MCP 不展开这份清单,占位记录读这个值。 */
274
294
  function mcpToolsRequireConfirmation(manifest) {
275
295
  const tools = manifest.tools;
@@ -301,6 +321,7 @@ function mcpDiscoverStub(pre, options) {
301
321
  if (options?.autoApprove && options.autoApprove.length > 0) {
302
322
  execution.autoApprove = options.autoApprove;
303
323
  }
324
+ execution.knownTools = mcpKnownToolNames(m);
304
325
  return {
305
326
  toolId: pre.toolId,
306
327
  name: pre.name,
@@ -73,7 +73,8 @@ export class DevMcpExecutor {
73
73
  const url = execution.server?.url ?? '';
74
74
  const cmd = execution.server?.command ?? '';
75
75
  const args = (execution.server?.args ?? []).join('\0');
76
- const key = `${url || `stdio:${cmd}:${args}`}\0${request.authContext?.userId ?? ''}\0${request.authContext?.agentCode ?? ''}`;
76
+ const scope = request.sessionId?.trim() || request.taskId?.trim() || '';
77
+ const key = `${url || `stdio:${cmd}:${args}`}\0${request.authContext?.userId ?? ''}\0${request.authContext?.agentCode ?? ''}\0${scope}`;
77
78
  const cached = this.listCache.get(key);
78
79
  if (cached)
79
80
  return cached;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 平台默认 Harness 原语:read_file / write_file / execute_command。
2
+ * 平台默认 Harness 原语:read_file / write_file / append_to_file / execute_command / datetime_to_unix / unix_to_datetime。
3
3
  *
4
4
  * 不写进 agent.manifest.toolsets。同名业务工具优先(问数 file-io 等)。
5
5
  * 写:`dirname(agentDir)/users/<userId>/`(含用户自己的 skills)。产物默认 `files/`,公开副本 `public/`。
@@ -56,6 +56,7 @@ export declare class HarnessPrimitiveExecutor implements IToolExecutor {
56
56
  private extraRootsFor;
57
57
  private requireWorkdir;
58
58
  execute(request: ToolCallRequest): Promise<ToolCallResult>;
59
+ private convertTime;
59
60
  private readFile;
60
61
  private writeFile;
61
62
  private executeCommand;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 平台默认 Harness 原语:read_file / write_file / execute_command。
2
+ * 平台默认 Harness 原语:read_file / write_file / append_to_file / execute_command / datetime_to_unix / unix_to_datetime。
3
3
  *
4
4
  * 不写进 agent.manifest.toolsets。同名业务工具优先(问数 file-io 等)。
5
5
  * 写:`dirname(agentDir)/users/<userId>/`(含用户自己的 skills)。产物默认 `files/`,公开副本 `public/`。
@@ -20,6 +20,7 @@ import { MAX_SNAPSHOT_ENUM, MAX_SNAPSHOT_URLS, PUBLIC_FILE_MODE, ensureUserSlots
20
20
  import { BUILTIN_TOOL_SOURCE, DEFAULT_BUILTIN_TIMEOUT_MS, MAX_BUILTIN_TIMEOUT_MS, clampBuiltinTimeout, isBuiltinToolExecution, resolveBuiltinToolsConfig, } from '@gt-fe/eap-contracts';
21
21
  import { ensureInterpreterShims, formatSpawnError, killProcessTree, resolveShellSpawn, sandboxEnv, spawnOptionsFor, substituteSkillDir, } from './harness-command.js';
22
22
  import { expandSkillScriptRefs, isSkillEntryFile } from './skill-script-refs.js';
23
+ import { resolveDatetimeToUnix, resolveUnixToDatetime } from './unix-time.js';
23
24
  /** 超时杀进程或 spawn 失败后等 `close`;未到则最多再等此时长再快照。 */
24
25
  const SPAWN_CLOSE_GRACE_MS = 1000;
25
26
  function builtinConfig() {
@@ -140,6 +141,64 @@ function harnessDefinitions() {
140
141
  },
141
142
  },
142
143
  },
144
+ {
145
+ toolId: 'builtin:datetime_to_unix',
146
+ name: 'datetime_to_unix',
147
+ manifest: {
148
+ id: 'builtin:datetime_to_unix',
149
+ name: 'datetime_to_unix',
150
+ description: '把日期或时刻换成 Unix 秒和毫秒。已有时间戳要换成日期时用 unix_to_datetime。返回 JSON:timezone、datetime、unix_seconds、unix_milliseconds;只写日期时另有 note。unix_seconds 与 unix_milliseconds 是数字字符串。按秒的参数填 unix_seconds,按毫秒的参数填 unix_milliseconds。',
151
+ ...common,
152
+ },
153
+ inputSchema: {
154
+ type: 'object',
155
+ additionalProperties: false,
156
+ properties: {
157
+ datetime: {
158
+ type: 'string',
159
+ description: '要换算的日期或时刻。省略或空字符串表示现在。只写日期按 timezone 当天 00:00:00,如 2027-01-01、2027年1月1日、2027/01/01。时刻如 2027-01-01 11:00,T 与空格相同。毫秒写在秒后,1 到 3 位,.1 表示 100 毫秒。不要写 Z、+08:00 或数字。',
160
+ },
161
+ timezone: {
162
+ type: 'string',
163
+ description: 'IANA 时区名,如 Asia/Shanghai、UTC、America/New_York。省略则用环境段的「时区」。',
164
+ },
165
+ },
166
+ },
167
+ },
168
+ {
169
+ toolId: 'builtin:unix_to_datetime',
170
+ name: 'unix_to_datetime',
171
+ manifest: {
172
+ id: 'builtin:unix_to_datetime',
173
+ name: 'unix_to_datetime',
174
+ description: '把 Unix 时间戳换成日期文字。日期换时间戳用 datetime_to_unix。返回 JSON:timezone、unit、formatted、format、unix_seconds、unix_milliseconds。',
175
+ ...common,
176
+ },
177
+ inputSchema: {
178
+ type: 'object',
179
+ additionalProperties: false,
180
+ required: ['timestamp'],
181
+ properties: {
182
+ timestamp: {
183
+ type: 'string',
184
+ description: '整数字符串。秒如 1798732800,毫秒如 1798732800000。不要传日期。',
185
+ },
186
+ unit: {
187
+ type: 'string',
188
+ enum: ['s', 'ms'],
189
+ description: 's 为秒,ms 为毫秒。省略时,绝对值达到 1000000000000(13 位)视为毫秒,否则视为秒。',
190
+ },
191
+ timezone: {
192
+ type: 'string',
193
+ description: 'IANA 时区名,如 Asia/Shanghai、UTC、America/New_York。省略则用环境段的「时区」。',
194
+ },
195
+ format: {
196
+ type: 'string',
197
+ description: 'formatted 的写法,默认 YYYY-MM-DD HH:mm:ss。记号只有 YYYY、MM(月)、DD、HH、mm(分)、ss、SSS。分钟写 mm。',
198
+ },
199
+ },
200
+ },
201
+ },
143
202
  ];
144
203
  }
145
204
  export function mergeHarnessPrimitives(loadedTools) {
@@ -401,6 +460,10 @@ export class HarnessPrimitiveExecutor {
401
460
  return await this.writeFile(request, true);
402
461
  case 'execute_command':
403
462
  return await this.executeCommand(request);
463
+ case 'datetime_to_unix':
464
+ return this.convertTime(request, resolveDatetimeToUnix);
465
+ case 'unix_to_datetime':
466
+ return this.convertTime(request, resolveUnixToDatetime);
404
467
  default:
405
468
  return fail(request, `未知 Harness 原语:${request.toolName}`);
406
469
  }
@@ -409,6 +472,12 @@ export class HarnessPrimitiveExecutor {
409
472
  return fail(request, err instanceof Error ? err.message : String(err));
410
473
  }
411
474
  }
475
+ convertTime(request, resolve) {
476
+ const resolved = resolve(request.arguments);
477
+ if (!resolved.ok)
478
+ return fail(request, resolved.error);
479
+ return ok(request, JSON.stringify(resolved.value));
480
+ }
412
481
  async readFile(request) {
413
482
  const filePath = String(request.arguments.path ?? '');
414
483
  const workdir = this.workdirFor(request);
@@ -25,6 +25,8 @@ export interface McpDiscoverExecution {
25
25
  bundlePath?: string;
26
26
  enabledTools?: string[];
27
27
  autoApprove?: string[];
28
+ /** 预装清单里已有的子工具名。缺省表示沿用 stub 的确认规则。 */
29
+ knownTools?: string[];
28
30
  }
29
31
  export declare function readMcpDiscoverExecution(raw: unknown): McpDiscoverExecution | null;
30
32
  export declare function isMcpDiscoverStub(def: LoadedToolDefinition): boolean;
@@ -71,6 +73,7 @@ export interface HydrateMcpToolsAuth {
71
73
  authContext: AuthContext;
72
74
  correlationId?: string;
73
75
  taskId?: string;
76
+ sessionId?: string;
74
77
  onListFailure?: (failure: {
75
78
  toolset: string;
76
79
  endpoint: string;
@@ -79,10 +82,13 @@ export interface HydrateMcpToolsAuth {
79
82
  }
80
83
  /**
81
84
  * 将 MCP discover stub 替换为 tools/list 结果。
82
- * 无 stub 时原样返回(同一引用)。按 endpoint+user+agent 在执行器内缓存 list。
85
+ * 无 stub 时原样返回(同一引用)。执行器按会话缓存 list。
83
86
  * enabledTools 为可选白名单;autoApprove 的子工具 requireConfirmation=false。
87
+ * knownTools 之外的新工具,不是只读时要用户确认。
84
88
  * 单个 endpoint 的 list 失败只跳过该工具集;全部失败才抛错(错误含工具集名)。
85
89
  */
86
90
  export declare function hydrateMcpTools(loadedTools: LoadedToolMap | undefined, executor: IToolExecutor | undefined, auth: HydrateMcpToolsAuth): Promise<LoadedToolMap | undefined>;
91
+ /** 登记过的名字沿用 stub;新名字在不是只读时要确认。没有 knownTools 时全部沿用 stub。 */
92
+ export declare function mcpChildNeedsConfirmation(tool: McpListedTool, stub: LoadedToolDefinition, autoApprove: boolean, known: Set<string> | null): boolean;
87
93
  /** 测试与 HITL 续跑:从已 hydrate 的 map 判断是否还要 list */
88
94
  export declare function needsMcpHydration(loadedTools: LoadedToolMap | undefined): boolean;
@@ -24,6 +24,7 @@ export function parseListedTools(result) {
24
24
  name: t.name,
25
25
  description: typeof t.description === 'string' ? t.description : undefined,
26
26
  inputSchema,
27
+ ...(t.annotations?.readOnlyHint === true ? { readOnly: true } : {}),
27
28
  });
28
29
  }
29
30
  return out;
@@ -144,8 +145,9 @@ export function toolTraceIdentity(loaded, toolName) {
144
145
  }
145
146
  /**
146
147
  * 将 MCP discover stub 替换为 tools/list 结果。
147
- * 无 stub 时原样返回(同一引用)。按 endpoint+user+agent 在执行器内缓存 list。
148
+ * 无 stub 时原样返回(同一引用)。执行器按会话缓存 list。
148
149
  * enabledTools 为可选白名单;autoApprove 的子工具 requireConfirmation=false。
150
+ * knownTools 之外的新工具,不是只读时要用户确认。
149
151
  * 单个 endpoint 的 list 失败只跳过该工具集;全部失败才抛错(错误含工具集名)。
150
152
  */
151
153
  export async function hydrateMcpTools(loadedTools, executor, auth) {
@@ -193,6 +195,7 @@ export async function hydrateMcpTools(loadedTools, executor, auth) {
193
195
  execution: primary.manifest.execution,
194
196
  ...(toolsetCode ? { toolsetCode } : {}),
195
197
  ...(primary.manifest.version ? { toolVersion: primary.manifest.version } : {}),
198
+ ...(auth.sessionId ? { sessionId: auth.sessionId } : {}),
196
199
  });
197
200
  }
198
201
  catch (err) {
@@ -213,12 +216,13 @@ export async function hydrateMcpTools(loadedTools, executor, auth) {
213
216
  const enabled = exec?.enabledTools;
214
217
  const enabledSet = enabled && enabled.length > 0 ? new Set(enabled) : null;
215
218
  const autoApprove = new Set(exec?.autoApprove ?? []);
219
+ const known = Array.isArray(exec?.knownTools) ? new Set(exec.knownTools) : null;
216
220
  const picked = enabledSet ? listed.filter((t) => enabledSet.has(t.name)) : listed;
217
221
  for (const tool of picked) {
218
222
  if (emitted.has(tool.name))
219
223
  continue;
220
224
  emitted.add(tool.name);
221
- result[tool.name] = listedToolToDefinition(tool, stub, autoApprove.has(tool.name));
225
+ result[tool.name] = listedToolToDefinition(tool, stub, mcpChildNeedsConfirmation(tool, stub, autoApprove.has(tool.name), known));
222
226
  }
223
227
  }
224
228
  }
@@ -227,11 +231,20 @@ export async function hydrateMcpTools(loadedTools, executor, auth) {
227
231
  }
228
232
  return result;
229
233
  }
230
- function listedToolToDefinition(tool, stub, autoApprove) {
234
+ /** 登记过的名字沿用 stub;新名字在不是只读时要确认。没有 knownTools 时全部沿用 stub。 */
235
+ export function mcpChildNeedsConfirmation(tool, stub, autoApprove, known) {
236
+ if (autoApprove)
237
+ return false;
238
+ if (known === null || known.has(tool.name))
239
+ return Boolean(stub.manifest.requireConfirmation);
240
+ return tool.readOnly !== true;
241
+ }
242
+ function listedToolToDefinition(tool, stub, requireConfirmation) {
231
243
  const exec = { ...(readMcpDiscoverExecution(stub.manifest.execution) ?? {}) };
232
244
  delete exec.discover;
233
245
  delete exec.enabledTools;
234
246
  delete exec.autoApprove;
247
+ delete exec.knownTools;
235
248
  const rest = exec;
236
249
  return {
237
250
  toolId: `${stub.toolId}:${tool.name}`,
@@ -245,7 +258,7 @@ function listedToolToDefinition(tool, stub, autoApprove) {
245
258
  description: tool.description,
246
259
  toolRuntime: stub.manifest.toolRuntime,
247
260
  dataScope: stub.manifest.dataScope,
248
- requireConfirmation: autoApprove ? false : Boolean(stub.manifest.requireConfirmation),
261
+ requireConfirmation,
249
262
  execution: {
250
263
  ...rest,
251
264
  protocol: 'mcp',
@@ -18,7 +18,7 @@ export declare class McpToolExecutor implements IToolExecutor {
18
18
  private readonly mcpUrl;
19
19
  private rpcId;
20
20
  private initialized;
21
- /** endpoint+user+agent+toolset+version → in-flight / completed tools/list */
21
+ /** 同一会话复用 tools/list。新会话或新 task 重新要。 */
22
22
  private readonly listCache;
23
23
  constructor(options: McpToolExecutorOptions);
24
24
  execute(request: ToolCallRequest): Promise<ToolCallResult>;
@@ -81,6 +81,7 @@ function mcpListCacheKey(mcpUrl, request) {
81
81
  request.authContext.agentCode,
82
82
  request.toolsetCode?.trim() ?? '',
83
83
  request.toolVersion?.trim() ?? '',
84
+ request.sessionId?.trim() || request.taskId?.trim() || '',
84
85
  ].join('\0');
85
86
  }
86
87
  export class McpToolExecutor {
@@ -89,7 +90,7 @@ export class McpToolExecutor {
89
90
  mcpUrl;
90
91
  rpcId = 1;
91
92
  initialized = false;
92
- /** endpoint+user+agent+toolset+version → in-flight / completed tools/list */
93
+ /** 同一会话复用 tools/list。新会话或新 task 重新要。 */
93
94
  listCache = new Map();
94
95
  constructor(options) {
95
96
  if (!isAbsoluteHttpUrl(options.baseUrl)) {
@@ -20,7 +20,9 @@
20
20
  *
21
21
  * @see docs/status/module-status-and-backlog.md RT-5
22
22
  */
23
+ import { resolveBuiltinToolsConfig } from '@gt-fe/eap-contracts';
23
24
  import { commandDialectLines } from './harness-command.js';
25
+ import { defaultTimezone } from './unix-time.js';
24
26
  // ─────────────────────────────────────────────
25
27
  // 平台规则常量(一年改不了两次,不值得搞配置表)
26
28
  // ─────────────────────────────────────────────
@@ -64,6 +66,21 @@ const PLATFORM_RULES = `你是运行在企业智能体平台(EAP)上的 AI
64
66
 
65
67
  ## 规则优先级
66
68
  以上平台规则最高。后面的身份、技能说明、Agent 基线、节点提示词与之冲突时,以平台规则为准。`;
69
+ /** 内置时间工具开着时,写进平台规则「时间」段。关掉内置工具则不写。 */
70
+ function timestampBan() {
71
+ return [
72
+ '- **禁止**自己计算 Unix 秒或毫秒。禁止心算,禁止用当前时间戳加减,禁止用 `execute_command`、`date` 或 python 换算。',
73
+ '- 环境段「当前时间戳(秒)」「当前时间戳(毫秒)」可以原样抄。',
74
+ '- 其它日期或时间戳**必须**调用 `datetime_to_unix`(日期换成时间戳)或 `unix_to_datetime`(时间戳换成日期),只使用返回的 `unix_seconds`、`unix_milliseconds` 或 `formatted`。',
75
+ '- 这两个工具省略 timezone 时,用环境段的「时区」。填入其它工具的秒或毫秒,必须是返回的 `unix_seconds` 或 `unix_milliseconds`。',
76
+ ].join('\n');
77
+ }
78
+ function platformRules() {
79
+ if (!resolveBuiltinToolsConfig(process.env).enabled)
80
+ return PLATFORM_RULES;
81
+ const anchor = '- 遇到非法日期(如 2 月 30 日)先告诉用户,不要擅自改写后静默查询。';
82
+ return PLATFORM_RULES.replace(anchor, `${anchor}\n${timestampBan()}`);
83
+ }
67
84
  export function toLoadedSkillMap(skills) {
68
85
  if (!skills?.length)
69
86
  return undefined;
@@ -136,7 +153,7 @@ export class SystemPromptBuilder {
136
153
  return;
137
154
  sections.push({ id, title, body: trimmed });
138
155
  };
139
- add('platform-rules', '平台规则', PLATFORM_RULES);
156
+ add('platform-rules', '平台规则', platformRules());
140
157
  add('environment', '环境', this.buildEnvironment(context));
141
158
  add('identity', 'Agent 身份', this.buildAgentIdentity(context));
142
159
  if (context.userId && context.userId.trim() && context.userId !== 'anonymous') {
@@ -154,15 +171,16 @@ export class SystemPromptBuilder {
154
171
  }
155
172
  buildEnvironment(context, now = new Date()) {
156
173
  const windows = naturalTimeWindows(now);
174
+ const zone = defaultTimezone();
175
+ const ms = now.getTime();
157
176
  const lines = [
158
177
  '## 环境',
178
+ `- 时区:${zone}。当前时间、周和月都按此时区。`,
159
179
  `- 当前时间:${windows.now}(${windows.weekday})`,
160
- `- 本周:${windows.thisWeek}`,
161
- `- 上周:${windows.lastWeek}`,
162
- `- 上周末:${windows.lastWeekend}`,
163
- `- 本月:${windows.thisMonth}`,
164
- `- 上月:${windows.lastMonth}`,
180
+ `- 当前时间戳(秒):${Math.floor(ms / 1000)}`,
181
+ `- 当前时间戳(毫秒):${ms}`,
165
182
  ];
183
+ lines.push(`- 本周:${windows.thisWeek}`, `- 上周:${windows.lastWeek}`, `- 上周末:${windows.lastWeekend}`, `- 本月:${windows.thisMonth}`, `- 上月:${windows.lastMonth}`);
166
184
  if (context.workingDirectory) {
167
185
  lines.push(`- 工作目录:${context.workingDirectory}`);
168
186
  }
@@ -255,7 +273,7 @@ export function naturalTimeWindows(now = new Date()) {
255
273
  const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
256
274
  const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0);
257
275
  return {
258
- now: `${formatDay(now)} ${pad2(now.getHours())}:${pad2(now.getMinutes())}`,
276
+ now: `${formatDay(now)} ${pad2(now.getHours())}:${pad2(now.getMinutes())}:${pad2(now.getSeconds())}`,
259
277
  weekday: weekdays[now.getDay()] ?? '',
260
278
  thisWeek: `${formatDay(thisMon)}(周一) ~ ${formatDay(thisSun)}(周日)`,
261
279
  lastWeek: `${formatDay(lastMon)}(周一) ~ ${formatDay(lastSun)}(周日)`,
@@ -38,6 +38,10 @@ export interface ToolCallRequest {
38
38
  taskId?: string;
39
39
  /** 链路关联 ID;缺省可用 toolCallId */
40
40
  correlationId?: string;
41
+ /**
42
+ * 会话标识。MCP tools/list 按会话缓存:同一会话复用名单,新会话重新向 Gateway 要。
43
+ */
44
+ sessionId?: string;
41
45
  /** 路由用:manifest type */
42
46
  toolType?: string;
43
47
  /** 路由用:manifest runtime */
@@ -143,6 +147,8 @@ export interface McpListedTool {
143
147
  name: string;
144
148
  description?: string;
145
149
  inputSchema?: Record<string, unknown>;
150
+ /** 上游 annotations.readOnlyHint 为 true。缺省视为会改数据。 */
151
+ readOnly?: boolean;
146
152
  }
147
153
  /**
148
154
  * IToolExecutor — Tool 执行网关接口
@@ -0,0 +1,31 @@
1
+ /**
2
+ * 两个方向分开:
3
+ * datetime_to_unix 只收日期字符串;unix_to_datetime 只收整数字符串时间戳。
4
+ * 省略 timezone 时用进程 IANA 时区,与环境段的当前时间相同。
5
+ */
6
+ /** 省略 timezone 时用的时区:进程 IANA 名,没有则 Asia/Shanghai。 */
7
+ export declare function defaultTimezone(): string;
8
+ export interface DatetimeToUnixValue {
9
+ timezone: string;
10
+ datetime: string;
11
+ unix_seconds: string;
12
+ unix_milliseconds: string;
13
+ note?: string;
14
+ }
15
+ export interface UnixToDatetimeValue {
16
+ timezone: string;
17
+ unit: 's' | 'ms';
18
+ formatted: string;
19
+ format: string;
20
+ unix_seconds: string;
21
+ unix_milliseconds: string;
22
+ }
23
+ export type TimeResolution<T> = {
24
+ ok: true;
25
+ value: T;
26
+ } | {
27
+ ok: false;
28
+ error: string;
29
+ };
30
+ export declare function resolveDatetimeToUnix(args: Record<string, unknown> | undefined): TimeResolution<DatetimeToUnixValue>;
31
+ export declare function resolveUnixToDatetime(args: Record<string, unknown> | undefined): TimeResolution<UnixToDatetimeValue>;
@@ -0,0 +1,304 @@
1
+ /**
2
+ * 两个方向分开:
3
+ * datetime_to_unix 只收日期字符串;unix_to_datetime 只收整数字符串时间戳。
4
+ * 省略 timezone 时用进程 IANA 时区,与环境段的当前时间相同。
5
+ */
6
+ const FALLBACK_TIMEZONE = 'Asia/Shanghai';
7
+ const DEFAULT_FORMAT = 'YYYY-MM-DD HH:mm:ss';
8
+ /** 省略 unit 时,绝对值达到此值视为毫秒,否则视为秒。 */
9
+ const MS_THRESHOLD = 1_000_000_000_000;
10
+ /** Date 可表示的绝对毫秒。超出后 Intl 会抛英文异常。 */
11
+ const MAX_ABS_MS = 8_640_000_000_000_000;
12
+ const FORMAT_TOKEN = 'YYYY|SSS|MM|DD|HH|mm|ss';
13
+ const DATE_EXAMPLE = '例如 2027-01-01、2027年1月1日、2027/01/01、2027-01-01 11:00、2027-01-01 11:00:00.123。';
14
+ const DATE_ONLY_NOTE = '只写了日期,按该时区当天 00:00:00。其它工具若要日期,传原来的日期,不要传这个零点的秒。';
15
+ /** 省略 timezone 时用的时区:进程 IANA 名,没有则 Asia/Shanghai。 */
16
+ export function defaultTimezone() {
17
+ try {
18
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
19
+ if (typeof zone === 'string' && zone.trim()) {
20
+ Intl.DateTimeFormat('en-US', { timeZone: zone });
21
+ return zone;
22
+ }
23
+ }
24
+ catch {
25
+ // 进程没有可用的 IANA 时区
26
+ }
27
+ return FALLBACK_TIMEZONE;
28
+ }
29
+ export function resolveDatetimeToUnix(args) {
30
+ const record = args ?? {};
31
+ const extra = unknownKeys(record, ['datetime', 'timezone']);
32
+ if (extra.length > 0) {
33
+ return fail(`datetime_to_unix 只接受 datetime、timezone,收到 ${extra.join('、')}。格式化时间戳请用 unix_to_datetime。`);
34
+ }
35
+ const timezone = readTimezone(record.timezone);
36
+ if (!timezone.ok)
37
+ return timezone;
38
+ const raw = record.datetime;
39
+ if (raw == null || (typeof raw === 'string' && raw.trim() === '')) {
40
+ return { ok: true, value: fromInstant(Date.now(), timezone.value, false) };
41
+ }
42
+ if (typeof raw === 'number' || (typeof raw === 'string' && /^[+-]?\d+$/.test(raw.trim()))) {
43
+ return fail('datetime 是数字。时间戳请用 unix_to_datetime 的 timestamp。');
44
+ }
45
+ if (typeof raw !== 'string') {
46
+ return fail('datetime 必须是字符串,例如 2027-01-01 或 2027-01-01 11:00。');
47
+ }
48
+ const text = raw.trim();
49
+ if (/[zZ]$/.test(text) || /[+-]\d{2}:?\d{2}$/.test(text)) {
50
+ return fail('datetime 带了时区。请去掉 Z 或偏移,改用 timezone。');
51
+ }
52
+ const parsed = parseWall(text);
53
+ if (!parsed.ok)
54
+ return parsed;
55
+ const ms = wallToInstant(parsed.value.civil, timezone.value);
56
+ if (ms == null)
57
+ return fail(`时区 ${timezone.value} 没有这个时刻:${text}。`);
58
+ return { ok: true, value: fromInstant(ms + parsed.value.millis, timezone.value, parsed.value.dateOnly) };
59
+ }
60
+ export function resolveUnixToDatetime(args) {
61
+ const record = args ?? {};
62
+ const extra = unknownKeys(record, ['timestamp', 'unit', 'timezone', 'format']);
63
+ if (extra.length > 0) {
64
+ return fail(`unix_to_datetime 只接受 timestamp、unit、timezone、format,收到 ${extra.join('、')}。日期换时间戳请用 datetime_to_unix。`);
65
+ }
66
+ const timezone = readTimezone(record.timezone);
67
+ if (!timezone.ok)
68
+ return timezone;
69
+ const format = readFormat(record.format);
70
+ if (!format.ok)
71
+ return format;
72
+ const unit = readUnit(record.unit);
73
+ if (!unit.ok)
74
+ return unit;
75
+ const raw = record.timestamp;
76
+ if (raw == null || (typeof raw === 'string' && raw.trim() === '')) {
77
+ return fail('timestamp 必填。秒如 1798732800,毫秒如 1798732800000。');
78
+ }
79
+ if (typeof raw !== 'number' && typeof raw !== 'string') {
80
+ return fail('timestamp 必须是整数字符串。秒如 1798732800,毫秒如 1798732800000。');
81
+ }
82
+ const text = typeof raw === 'number' ? String(raw) : raw.trim();
83
+ if (!/^[+-]?\d+$/.test(text)) {
84
+ return fail('timestamp 必须是整数字符串。日期请用 datetime_to_unix 的 datetime。');
85
+ }
86
+ if (text.replace(/^[+-]/, '').length > 15)
87
+ return fail('时间戳超出可表示范围。');
88
+ const n = Number(text);
89
+ if (!Number.isSafeInteger(n))
90
+ return fail('时间戳超出可表示范围。');
91
+ const resolved = unit.value ?? (Math.abs(n) >= MS_THRESHOLD ? 'ms' : 's');
92
+ const ms = resolved === 'ms' ? n : n * 1000;
93
+ if (!Number.isFinite(ms) || Math.abs(ms) > MAX_ABS_MS)
94
+ return fail('时间戳超出可表示范围。');
95
+ let civil;
96
+ try {
97
+ civil = partsInZone(ms, timezone.value);
98
+ }
99
+ catch {
100
+ return fail('时间戳超出可表示范围。');
101
+ }
102
+ if (!Number.isFinite(civil.year))
103
+ return fail('时间戳超出可表示范围。');
104
+ return {
105
+ ok: true,
106
+ value: {
107
+ timezone: timezone.value,
108
+ unit: resolved,
109
+ formatted: applyFormat(civil, ms, format.value),
110
+ format: format.value,
111
+ unix_seconds: String(Math.floor(ms / 1000)),
112
+ unix_milliseconds: String(ms),
113
+ },
114
+ };
115
+ }
116
+ function fromInstant(ms, timezone, dateOnly) {
117
+ const civil = partsInZone(ms, timezone);
118
+ const frac = ((ms % 1000) + 1000) % 1000;
119
+ let datetime = applyFormat(civil, ms, DEFAULT_FORMAT);
120
+ if (frac !== 0)
121
+ datetime += `.${String(frac).padStart(3, '0')}`;
122
+ const value = {
123
+ timezone,
124
+ datetime,
125
+ unix_seconds: String(Math.floor(ms / 1000)),
126
+ unix_milliseconds: String(ms),
127
+ };
128
+ if (dateOnly)
129
+ value.note = DATE_ONLY_NOTE;
130
+ return value;
131
+ }
132
+ function unknownKeys(record, allowed) {
133
+ return Object.keys(record).filter((key) => !allowed.includes(key));
134
+ }
135
+ function readTimezone(raw) {
136
+ if (raw == null || (typeof raw === 'string' && raw.trim() === '')) {
137
+ return { ok: true, value: defaultTimezone() };
138
+ }
139
+ if (typeof raw !== 'string')
140
+ return fail('timezone 必须是 IANA 时区名,例如 Asia/Shanghai 或 UTC。');
141
+ const timezone = raw.trim();
142
+ try {
143
+ Intl.DateTimeFormat('en-US', { timeZone: timezone });
144
+ }
145
+ catch {
146
+ return fail(`时区无效:${timezone}。请用 IANA 名,例如 Asia/Shanghai、UTC、America/New_York。`);
147
+ }
148
+ return { ok: true, value: timezone };
149
+ }
150
+ function readFormat(raw) {
151
+ if (raw == null || (typeof raw === 'string' && raw.trim() === '')) {
152
+ return { ok: true, value: DEFAULT_FORMAT };
153
+ }
154
+ if (typeof raw !== 'string') {
155
+ return fail('format 必须是字符串。记号只有 YYYY、MM、DD、HH、mm、ss、SSS。');
156
+ }
157
+ const format = raw.trim();
158
+ const token = new RegExp(FORMAT_TOKEN, 'g');
159
+ const stripped = format.replace(token, '');
160
+ if (/[A-Za-z]/.test(stripped) || !new RegExp(FORMAT_TOKEN).test(format)) {
161
+ return fail(`format 无法使用:${format}。记号只有 YYYY、MM、DD、HH、mm、ss、SSS。月是 MM,分是 mm。例如 YYYY-MM-DD HH:mm:ss。`);
162
+ }
163
+ if (/HH[^A-Za-z]*MM/.test(format)) {
164
+ return fail(`format 把分钟写成了 MM:${format}。分钟用 mm,月份用 MM。例如 YYYY-MM-DD HH:mm:ss。`);
165
+ }
166
+ if (/YYYY[^A-Za-z]*mm/.test(format)) {
167
+ return fail(`format 把月份写成了 mm:${format}。月份用 MM,分钟用 mm。例如 YYYY-MM-DD HH:mm:ss。`);
168
+ }
169
+ return { ok: true, value: format };
170
+ }
171
+ function readUnit(raw) {
172
+ if (raw == null || raw === '')
173
+ return { ok: true, value: undefined };
174
+ if (raw === 's' || raw === 'ms')
175
+ return { ok: true, value: raw };
176
+ return fail('unit 只能是 s 或 ms。s 为秒,ms 为毫秒。');
177
+ }
178
+ function parseWall(text) {
179
+ if (/\.\d{4,}/.test(text))
180
+ return fail(`毫秒最多三位。${DATE_EXAMPLE}`);
181
+ const isoDate = /^(\d{4})([-/])(\d{1,2})\2(\d{1,2})$/.exec(text);
182
+ if (isoDate)
183
+ return accept(text, true, isoDate[1], isoDate[3], isoDate[4]);
184
+ const isoTime = /^(\d{4})([-/])(\d{1,2})\2(\d{1,2})[T ](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?(?:\.(\d{1,3}))?$/.exec(text);
185
+ if (isoTime)
186
+ return accept(text, false, isoTime[1], isoTime[3], isoTime[4], isoTime[5], isoTime[6], isoTime[7], isoTime[8]);
187
+ const cnDate = /^(\d{4})年(\d{1,2})月(\d{1,2})日$/.exec(text);
188
+ if (cnDate)
189
+ return accept(text, true, cnDate[1], cnDate[2], cnDate[3]);
190
+ const cnTime = /^(\d{4})年(\d{1,2})月(\d{1,2})日[ T]?(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?(?:\.(\d{1,3}))?$/.exec(text);
191
+ if (cnTime)
192
+ return accept(text, false, cnTime[1], cnTime[2], cnTime[3], cnTime[4], cnTime[5], cnTime[6], cnTime[7]);
193
+ const cnHan = /^(\d{4})年(\d{1,2})月(\d{1,2})日[ T]?(\d{1,2})时(\d{1,2})分(?:(\d{1,2})秒)?$/.exec(text);
194
+ if (cnHan)
195
+ return accept(text, false, cnHan[1], cnHan[2], cnHan[3], cnHan[4], cnHan[5], cnHan[6]);
196
+ return fail(`看不懂这个时间:${text}。${DATE_EXAMPLE}`);
197
+ }
198
+ function accept(text, dateOnly, year, month, day, hour, minute, second, fraction) {
199
+ const civil = checkedCivil({
200
+ year: Number(year),
201
+ month: Number(month),
202
+ day: Number(day),
203
+ hour: dateOnly ? 0 : Number(hour),
204
+ minute: dateOnly ? 0 : Number(minute),
205
+ second: dateOnly ? 0 : Number(second ?? 0),
206
+ });
207
+ if (!civil)
208
+ return fail(`${dateOnly ? '日期' : '时刻'}无效:${text}。`);
209
+ const millis = fraction ? Number(fraction.padEnd(3, '0')) : 0;
210
+ return { ok: true, value: { civil, millis, dateOnly } };
211
+ }
212
+ function checkedCivil(civil) {
213
+ if (civil.year < 1000 || civil.year > 9999)
214
+ return null;
215
+ if (civil.month < 1 || civil.month > 12 || civil.day < 1)
216
+ return null;
217
+ if (civil.hour > 23 || civil.minute > 59 || civil.second > 59)
218
+ return null;
219
+ const probe = new Date(Date.UTC(civil.year, civil.month - 1, civil.day));
220
+ if (probe.getUTCFullYear() !== civil.year ||
221
+ probe.getUTCMonth() !== civil.month - 1 ||
222
+ probe.getUTCDate() !== civil.day) {
223
+ return null;
224
+ }
225
+ return civil;
226
+ }
227
+ /** 墙上时间换成 UTC 毫秒。重叠的本地时刻保留第一次校正后仍能对上的那个。缺口返回 null。 */
228
+ function wallToInstant(civil, timeZone) {
229
+ const desired = Date.UTC(civil.year, civil.month - 1, civil.day, civil.hour, civil.minute, civil.second);
230
+ let instant = desired - offsetMs(timeZone, desired);
231
+ const corrected = desired - offsetMs(timeZone, instant);
232
+ if (corrected !== instant)
233
+ instant = corrected;
234
+ const back = partsInZone(instant, timeZone);
235
+ if (back.year !== civil.year ||
236
+ back.month !== civil.month ||
237
+ back.day !== civil.day ||
238
+ back.hour !== civil.hour ||
239
+ back.minute !== civil.minute ||
240
+ back.second !== civil.second) {
241
+ return null;
242
+ }
243
+ return instant;
244
+ }
245
+ function offsetMs(timeZone, instant) {
246
+ const civil = partsInZone(instant, timeZone);
247
+ const asUtc = Date.UTC(civil.year, civil.month - 1, civil.day, civil.hour, civil.minute, civil.second);
248
+ return asUtc - instant;
249
+ }
250
+ function partsInZone(ms, timeZone) {
251
+ const formatted = new Intl.DateTimeFormat('en-US', {
252
+ timeZone,
253
+ hourCycle: 'h23',
254
+ year: 'numeric',
255
+ month: '2-digit',
256
+ day: '2-digit',
257
+ hour: '2-digit',
258
+ minute: '2-digit',
259
+ second: '2-digit',
260
+ }).formatToParts(new Date(ms));
261
+ const bag = {};
262
+ for (const part of formatted) {
263
+ if (part.type !== 'literal')
264
+ bag[part.type] = part.value;
265
+ }
266
+ let year = Number(bag.year);
267
+ let month = Number(bag.month);
268
+ let day = Number(bag.day);
269
+ let hour = Number(bag.hour);
270
+ if (hour === 24) {
271
+ hour = 0;
272
+ const next = new Date(Date.UTC(year, month - 1, day) + 24 * 60 * 60 * 1000);
273
+ year = next.getUTCFullYear();
274
+ month = next.getUTCMonth() + 1;
275
+ day = next.getUTCDate();
276
+ }
277
+ return {
278
+ year,
279
+ month,
280
+ day,
281
+ hour,
282
+ minute: Number(bag.minute),
283
+ second: Number(bag.second),
284
+ };
285
+ }
286
+ function applyFormat(civil, ms, pattern) {
287
+ const sub = ((ms % 1000) + 1000) % 1000;
288
+ const map = {
289
+ YYYY: String(civil.year).padStart(4, '0'),
290
+ MM: pad(civil.month),
291
+ DD: pad(civil.day),
292
+ HH: pad(civil.hour),
293
+ mm: pad(civil.minute),
294
+ ss: pad(civil.second),
295
+ SSS: String(sub).padStart(3, '0'),
296
+ };
297
+ return pattern.replace(new RegExp(FORMAT_TOKEN, 'g'), (token) => map[token] ?? token);
298
+ }
299
+ function pad(n) {
300
+ return String(n).padStart(2, '0');
301
+ }
302
+ function fail(error) {
303
+ return { ok: false, error };
304
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gt-fe/eap-runtime",
3
- "version": "0.0.35",
3
+ "version": "0.0.37",
4
4
  "description": "Enterprise Agent Platform — Runtime核心:Graph执行引擎、Task生命周期、Session管理、Checkpoint持久化",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -20,7 +20,7 @@
20
20
  "ioredis": "^5.4.0",
21
21
  "pg": "^8.13.0",
22
22
  "yaml": "^2.6.1",
23
- "@gt-fe/eap-contracts": "0.0.17"
23
+ "@gt-fe/eap-contracts": "0.0.19"
24
24
  },
25
25
  "publishConfig": {
26
26
  "registry": "https://registry.npmjs.org/",