@miphamai/cli 0.38.0 → 0.39.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/bin/mipham.ts +25 -0
- package/package.json +1 -1
- package/src/agent/sub-agent.ts +4 -3
- package/src/config/defaults.ts +19 -1
- package/src/config/keys-manager.ts +11 -1
- package/src/config/loader.ts +20 -10
- package/src/core/context.ts +86 -56
- package/src/core/credential-masker/output-scrub.ts +5 -0
- package/src/core/crsi-sandbox.ts +19 -7
- package/src/core/engine.ts +50 -33
- package/src/core/instructions.ts +4 -42
- package/src/core/permission.ts +7 -1
- package/src/core/red-team.ts +4 -4
- package/src/core/self-critique.ts +3 -1
- package/src/core/session-log.ts +225 -0
- package/src/core/session-store.ts +119 -34
- package/src/daemon/auth.ts +9 -6
- package/src/daemon/cors.ts +24 -16
- package/src/daemon/rate-limiter.ts +4 -0
- package/src/daemon/server.ts +24 -3
- package/src/index.tsx +54 -28
- package/src/mcp/http-transport.ts +2 -0
- package/src/mcp/oauth.ts +1 -1
- package/src/providers/llm-replay.ts +36 -0
- package/src/providers/llm.ts +16 -0
- package/src/providers/registry.ts +2 -1
- package/src/security/gate.ts +10 -0
- package/src/security/url.ts +37 -14
- package/src/shared/constants.ts +4 -486
- package/src/shared/types.ts +6 -437
- package/src/skills/fork-executor.ts +3 -1
- package/src/skills/loader.ts +2 -1
- package/src/skills/seam.ts +18 -0
- package/src/tools/agent/agent.ts +1 -0
- package/src/tools/agent/skill.ts +1 -0
- package/src/tools/exec/bash.ts +140 -128
- package/src/tools/exec/git.ts +17 -0
- package/src/tools/file/read.ts +73 -66
- package/src/tools/file/write.ts +1 -1
- package/src/tools/index.ts +54 -132
- package/src/tools/network/web-fetch.ts +41 -26
- package/src/tools/seam.ts +25 -0
- package/src/tools/validation.ts +91 -0
- package/src/ui/commands.ts +17 -5
- package/src/vajra/compose/assemble.ts +19 -0
- package/src/vajra/compose/bundle.ts +39 -0
- package/src/vajra/compose/dump.ts +5 -0
- package/src/vajra/compose/index.ts +4 -0
- package/src/vajra/compose/mount.ts +30 -0
- package/src/vajra/context.ts +183 -0
- package/src/vajra/events.ts +13 -0
- package/src/vajra/index.ts +4 -0
- package/src/vajra/leaf/plan-runner.ts +94 -0
- package/src/vajra/service.ts +15 -0
- package/src/workflow/primitives/agent.ts +3 -1
- package/src/workflow/runtime.ts +23 -15
- package/src/workflow/sandbox.ts +51 -9
- package/src/core/compaction-progress.ts +0 -205
- package/src/core/context-compact.ts +0 -124
- package/src/core/context-drain.ts +0 -113
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { EventsOfMode } from './events'
|
|
2
|
+
import type { Service, Mounted, ServiceStatus } from './service'
|
|
3
|
+
|
|
4
|
+
export type Disposer = () => void
|
|
5
|
+
|
|
6
|
+
type Listener = (...args: any[]) => any
|
|
7
|
+
|
|
8
|
+
export class Context {
|
|
9
|
+
private services = new Map<string, unknown>()
|
|
10
|
+
private effects: Disposer[] = []
|
|
11
|
+
private listeners = new Map<string, Listener[]>()
|
|
12
|
+
private waiters: Array<() => boolean> = []
|
|
13
|
+
private scopes = new Map<unknown, Context>()
|
|
14
|
+
readonly parent?: Context
|
|
15
|
+
|
|
16
|
+
constructor(parent?: Context) {
|
|
17
|
+
this.parent = parent
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
provide<T>(key: string, value: T): Disposer {
|
|
21
|
+
this.services.set(key, value)
|
|
22
|
+
this.flushWaiters()
|
|
23
|
+
return () => {
|
|
24
|
+
this.services.delete(key)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private flushWaiters(): void {
|
|
29
|
+
this.waiters = this.waiters.filter((w) => !w())
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
mount(service: Service): Mounted {
|
|
33
|
+
const keys = service.inject ?? []
|
|
34
|
+
let status: ServiceStatus = 'inactive'
|
|
35
|
+
let error: Error | undefined
|
|
36
|
+
let disposer: Disposer = () => {}
|
|
37
|
+
let applied = false
|
|
38
|
+
let disposed = false
|
|
39
|
+
let effectSnapshot = -1
|
|
40
|
+
|
|
41
|
+
const tryApply = (): boolean => {
|
|
42
|
+
if (applied) return true
|
|
43
|
+
if (!keys.every((k) => this.has(k))) return false
|
|
44
|
+
applied = true
|
|
45
|
+
status = 'loading'
|
|
46
|
+
effectSnapshot = this.effects.length
|
|
47
|
+
try {
|
|
48
|
+
disposer = service.apply(this) ?? (() => {})
|
|
49
|
+
status = 'active'
|
|
50
|
+
} catch (e) {
|
|
51
|
+
error = e as Error
|
|
52
|
+
status = 'failed'
|
|
53
|
+
}
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const waiter = (): boolean => {
|
|
58
|
+
if (disposed) {
|
|
59
|
+
this.waiters = this.waiters.filter((w) => w !== waiter)
|
|
60
|
+
return true
|
|
61
|
+
}
|
|
62
|
+
if (!tryApply()) return false
|
|
63
|
+
this.waiters = this.waiters.filter((w) => w !== waiter)
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!tryApply()) {
|
|
68
|
+
this.waiters.push(waiter)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const mounted: Mounted = {
|
|
72
|
+
dispose: () => {
|
|
73
|
+
disposed = true
|
|
74
|
+
this.waiters = this.waiters.filter((w) => w !== waiter)
|
|
75
|
+
if (status === 'active') {
|
|
76
|
+
status = 'unloading'
|
|
77
|
+
disposer()
|
|
78
|
+
}
|
|
79
|
+
if (effectSnapshot >= 0) {
|
|
80
|
+
while (this.effects.length > effectSnapshot) {
|
|
81
|
+
this.effects.pop()!()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
status: () => status,
|
|
86
|
+
}
|
|
87
|
+
Object.defineProperty(mounted, 'error', { get: () => error })
|
|
88
|
+
return mounted
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get<T>(key: string): T | undefined {
|
|
92
|
+
return (this.services.get(key) as T | undefined) ?? this.parent?.get<T>(key)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
has(key: string): boolean {
|
|
96
|
+
return this.services.has(key) || (this.parent?.has(key) ?? false)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
keys(): string[] {
|
|
100
|
+
return [...this.services.keys()]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 枚举本层 + 父层所有键(local 遮蔽 parent,去重)。 */
|
|
104
|
+
keysRecursive(): string[] {
|
|
105
|
+
const seen = new Set<string>(this.keys())
|
|
106
|
+
for (const k of this.parent?.keysRecursive() ?? []) seen.add(k)
|
|
107
|
+
return [...seen]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
scope(key: unknown): Context {
|
|
111
|
+
let child = this.scopes.get(key)
|
|
112
|
+
if (!child) {
|
|
113
|
+
child = new Context(this)
|
|
114
|
+
this.scopes.set(key, child)
|
|
115
|
+
}
|
|
116
|
+
return child
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
effect(fn: () => Disposer | void): Disposer {
|
|
120
|
+
const d = fn() ?? (() => {})
|
|
121
|
+
let disposed = false
|
|
122
|
+
const dispose = () => {
|
|
123
|
+
if (disposed) return
|
|
124
|
+
disposed = true
|
|
125
|
+
d()
|
|
126
|
+
}
|
|
127
|
+
this.effects.push(dispose)
|
|
128
|
+
return dispose
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
on(event: string, fn: Listener): Disposer {
|
|
132
|
+
const ls = this.listeners.get(event) ?? []
|
|
133
|
+
ls.push(fn)
|
|
134
|
+
this.listeners.set(event, ls)
|
|
135
|
+
return () => {
|
|
136
|
+
const cur = this.listeners.get(event)
|
|
137
|
+
if (cur)
|
|
138
|
+
this.listeners.set(
|
|
139
|
+
event,
|
|
140
|
+
cur.filter((l) => l !== fn),
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
emit(event: EventsOfMode<'emit'>, ...args: unknown[]): void {
|
|
146
|
+
for (const fn of this.listeners.get(event) ?? []) fn(...args)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async waterfall<T>(event: EventsOfMode<'waterfall'>, value: T, ...args: unknown[]): Promise<T> {
|
|
150
|
+
const ls = this.listeners.get(event) ?? []
|
|
151
|
+
const run = async (i: number, v: T): Promise<T> => {
|
|
152
|
+
if (i >= ls.length) return v
|
|
153
|
+
const next = async (nextVal?: T) => run(i + 1, nextVal === undefined ? v : nextVal)
|
|
154
|
+
return (await ls[i]!(v, ...args, next)) as T
|
|
155
|
+
}
|
|
156
|
+
return run(0, value)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async parallel(event: EventsOfMode<'parallel'>, ...args: unknown[]): Promise<unknown[]> {
|
|
160
|
+
return Promise.all((this.listeners.get(event) ?? []).map((fn) => fn(...args)))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async serial(event: EventsOfMode<'serial'>, ...args: unknown[]): Promise<unknown[]> {
|
|
164
|
+
const out: unknown[] = []
|
|
165
|
+
for (const fn of this.listeners.get(event) ?? []) out.push(await fn(...args))
|
|
166
|
+
return out
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
dispose(): void {
|
|
170
|
+
const errors: unknown[] = []
|
|
171
|
+
while (this.effects.length) {
|
|
172
|
+
try {
|
|
173
|
+
this.effects.pop()!()
|
|
174
|
+
} catch (e) {
|
|
175
|
+
errors.push(e)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
this.services.clear()
|
|
179
|
+
this.scopes.clear()
|
|
180
|
+
this.listeners.clear()
|
|
181
|
+
if (errors.length) throw new AggregateError(errors)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type DispatchMode = 'emit' | 'waterfall' | 'parallel' | 'serial'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 事件契约映射。每个事件名声明其派发模式;里程碑/测试通过
|
|
5
|
+
* declaration merging 扩展本 interface(无需改内核)。
|
|
6
|
+
*/
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
8
|
+
export interface EventMap {}
|
|
9
|
+
|
|
10
|
+
/** 提取声明为指定 mode 的事件名联合。 */
|
|
11
|
+
export type EventsOfMode<M extends DispatchMode> = {
|
|
12
|
+
[K in keyof EventMap]: EventMap[K] extends { mode: M } ? K : never
|
|
13
|
+
}[keyof EventMap]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Service } from '../index'
|
|
2
|
+
import type { Llm } from '../../providers/llm'
|
|
3
|
+
import type { BundleLine } from '../compose/bundle'
|
|
4
|
+
|
|
5
|
+
export type PlanTask = { id: string; description: string }
|
|
6
|
+
export type Plan = { name: string; tasks: PlanTask[] }
|
|
7
|
+
export type TaskOutcome = {
|
|
8
|
+
taskId: string
|
|
9
|
+
status: 'done' | 'needs-changes' | 'error'
|
|
10
|
+
result: string
|
|
11
|
+
review: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const PLAN_RUNNER_KEY = 'plan-runner'
|
|
15
|
+
|
|
16
|
+
export interface PlanRunner {
|
|
17
|
+
run(plan: Plan): Promise<TaskOutcome[]>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 事件契约:declaration merging 扩展 EventMap(内核首例,不改内核)
|
|
21
|
+
declare module '../events' {
|
|
22
|
+
interface EventMap {
|
|
23
|
+
'plan/task-start': { mode: 'emit'; in: { taskId: string } }
|
|
24
|
+
'plan/task-done': {
|
|
25
|
+
mode: 'emit'
|
|
26
|
+
in: { taskId: string; status: 'done' | 'needs-changes' | 'error' }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function chatText(llm: Llm, prompt: string, model: string): Promise<string> {
|
|
32
|
+
let text = ''
|
|
33
|
+
for await (const chunk of llm.chat({
|
|
34
|
+
model,
|
|
35
|
+
messages: [{ role: 'user', content: prompt }],
|
|
36
|
+
})) {
|
|
37
|
+
if (chunk.type === 'text' && chunk.content) text += chunk.content
|
|
38
|
+
}
|
|
39
|
+
return text
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 创建 plan-runner Service。model 缺省为空串(「用 active model」,由 ProviderRegistry 的 req.model || activeModelId 回退);传值则锁定该模型。 */
|
|
43
|
+
export function createPlanRunnerService(options: { model?: string } = {}): Service {
|
|
44
|
+
const model = options.model ?? ''
|
|
45
|
+
return {
|
|
46
|
+
inject: ['llm'],
|
|
47
|
+
apply(ctx) {
|
|
48
|
+
const runner: PlanRunner = {
|
|
49
|
+
async run(plan) {
|
|
50
|
+
const outcomes: TaskOutcome[] = []
|
|
51
|
+
for (const task of plan.tasks) {
|
|
52
|
+
ctx.emit('plan/task-start', { taskId: task.id })
|
|
53
|
+
const taskCtx = ctx.scope(task.id) // 每任务独立作用域(继承父层 llm 缝)
|
|
54
|
+
let result = ''
|
|
55
|
+
let review = ''
|
|
56
|
+
try {
|
|
57
|
+
result = await chatText(
|
|
58
|
+
taskCtx.get<Llm>('llm')!,
|
|
59
|
+
`Implement: ${task.description}`,
|
|
60
|
+
model,
|
|
61
|
+
)
|
|
62
|
+
review = await chatText(
|
|
63
|
+
taskCtx.get<Llm>('llm')!,
|
|
64
|
+
`Review: does the result satisfy "${task.description}"? Result: ${result}`,
|
|
65
|
+
model,
|
|
66
|
+
)
|
|
67
|
+
} catch (e) {
|
|
68
|
+
outcomes.push({ taskId: task.id, status: 'error', result, review: String(e) })
|
|
69
|
+
ctx.emit('plan/task-done', { taskId: task.id, status: 'error' })
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
const status: TaskOutcome['status'] = review.startsWith('APPROVE')
|
|
73
|
+
? 'done'
|
|
74
|
+
: 'needs-changes'
|
|
75
|
+
outcomes.push({ taskId: task.id, status, result, review })
|
|
76
|
+
ctx.emit('plan/task-done', { taskId: task.id, status })
|
|
77
|
+
}
|
|
78
|
+
return outcomes
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
ctx.provide(PLAN_RUNNER_KEY, runner)
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 默认 plan-runner Service(model 空串 = 用 active model)。 */
|
|
87
|
+
export const planRunnerService: Service = createPlanRunnerService()
|
|
88
|
+
|
|
89
|
+
/** 从 bundle 行解析 plan-runner Service(读 config.model 传工厂;id 非 plan-runner 或纯数据行返回 undefined)。 */
|
|
90
|
+
export function planRunnerFromLine(line: BundleLine): Service | undefined {
|
|
91
|
+
if (line.id !== 'plan-runner') return undefined
|
|
92
|
+
const model = typeof line.config.model === 'string' ? line.config.model : undefined
|
|
93
|
+
return createPlanRunnerService({ model })
|
|
94
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Context } from './context'
|
|
2
|
+
import type { Disposer } from './context'
|
|
3
|
+
|
|
4
|
+
export interface Service {
|
|
5
|
+
inject?: string[]
|
|
6
|
+
apply(ctx: Context): void | Disposer
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ServiceStatus = 'inactive' | 'loading' | 'active' | 'unloading' | 'failed'
|
|
10
|
+
|
|
11
|
+
export interface Mounted {
|
|
12
|
+
dispose(): void
|
|
13
|
+
status(): ServiceStatus
|
|
14
|
+
readonly error?: Error
|
|
15
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SubAgent } from '../../agent/sub-agent'
|
|
2
2
|
import type { ProviderRegistry } from '../../providers/registry'
|
|
3
|
+
import type { Llm } from '../../providers/llm'
|
|
3
4
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
4
5
|
import type { PermissionSystem } from '../../core/permission'
|
|
5
6
|
import { validateJSONSchema, formatValidationErrors } from '../schema-validator'
|
|
@@ -41,6 +42,7 @@ export async function workflowAgent(
|
|
|
41
42
|
registry: ProviderRegistry,
|
|
42
43
|
toolRegistry: Map<string, ToolDefinition>,
|
|
43
44
|
opts: WorkflowAgentOpts = {},
|
|
45
|
+
llm?: Llm,
|
|
44
46
|
): Promise<unknown> {
|
|
45
47
|
// If provider override, switch temporarily
|
|
46
48
|
if (opts.provider) {
|
|
@@ -82,7 +84,7 @@ export async function workflowAgent(
|
|
|
82
84
|
`Return ONLY the JSON object, no other text. Do not wrap in markdown code fences.`
|
|
83
85
|
}
|
|
84
86
|
|
|
85
|
-
const sub = new SubAgent(registry, toolRegistry, opts.permissionSystem)
|
|
87
|
+
const sub = new SubAgent(registry, toolRegistry, opts.permissionSystem, undefined, undefined, llm)
|
|
86
88
|
|
|
87
89
|
// ── Execute with optional schema validation + retry ──
|
|
88
90
|
let result: unknown = ''
|
package/src/workflow/runtime.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import vm from 'node:vm'
|
|
2
|
-
import { createSandbox } from './sandbox'
|
|
2
|
+
import { createSandbox, sealValue } from './sandbox'
|
|
3
3
|
import { createJournal, appendJournal, loadJournal, loadScript } from './journal'
|
|
4
4
|
import { createBudget } from './budget'
|
|
5
5
|
import { workflowAgent } from './primitives/agent'
|
|
@@ -103,6 +103,7 @@ export async function runWorkflow(
|
|
|
103
103
|
const registry: ProviderRegistry = engine.getRegistry()
|
|
104
104
|
const toolRegistry = engine.getTools()
|
|
105
105
|
const permission = engine.getPermission()
|
|
106
|
+
const llm = engine.getLlm()
|
|
106
107
|
|
|
107
108
|
const budget = createBudget(budgetTotal)
|
|
108
109
|
|
|
@@ -124,10 +125,16 @@ export async function runWorkflow(
|
|
|
124
125
|
|
|
125
126
|
bus.emitEvent({ type: 'agent:start', agentId, label: agentId, phase })
|
|
126
127
|
|
|
127
|
-
const result = await workflowAgent(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
const result = await workflowAgent(
|
|
129
|
+
prompt,
|
|
130
|
+
registry,
|
|
131
|
+
toolRegistry,
|
|
132
|
+
{
|
|
133
|
+
...(opts || {}),
|
|
134
|
+
permissionSystem: permission,
|
|
135
|
+
} as Record<string, unknown>,
|
|
136
|
+
llm,
|
|
137
|
+
)
|
|
131
138
|
|
|
132
139
|
const durationMs = Date.now() - startTime
|
|
133
140
|
bus.emitEvent({ type: 'agent:end', agentId, label: agentId, success: true, durationMs })
|
|
@@ -159,19 +166,20 @@ export async function runWorkflow(
|
|
|
159
166
|
})()
|
|
160
167
|
`
|
|
161
168
|
|
|
162
|
-
// Execute in node:vm
|
|
169
|
+
// Execute in a node:vm context (deterministic replay; NOT a security boundary).
|
|
163
170
|
const vmScript = new vm.Script(wrappedScript, { filename: 'workflow.js' })
|
|
164
171
|
const sandboxCtx = createSandbox(args, budget)
|
|
165
172
|
|
|
166
|
-
// Inject primitives into the
|
|
167
|
-
|
|
168
|
-
sandboxCtx.
|
|
169
|
-
sandboxCtx.
|
|
170
|
-
sandboxCtx.
|
|
171
|
-
sandboxCtx.
|
|
172
|
-
sandboxCtx.
|
|
173
|
-
sandboxCtx.
|
|
174
|
-
sandboxCtx.
|
|
173
|
+
// Inject primitives into the context (sealed so their constructor chain
|
|
174
|
+
// can't reach host globals via `fn.constructor('return process')()`).
|
|
175
|
+
sandboxCtx.agent = sealValue(agent)
|
|
176
|
+
sandboxCtx.parallel = sealValue(parallel)
|
|
177
|
+
sandboxCtx.pipeline = sealValue(pipeline)
|
|
178
|
+
sandboxCtx.verify = sealValue(verify)
|
|
179
|
+
sandboxCtx.judge = sealValue(judge)
|
|
180
|
+
sandboxCtx.loopUntilConvergence = sealValue(loopUntilConvergence)
|
|
181
|
+
sandboxCtx.phase = sealValue(wrappedPhase)
|
|
182
|
+
sandboxCtx.log = sealValue(log)
|
|
175
183
|
|
|
176
184
|
let result: unknown
|
|
177
185
|
try {
|
package/src/workflow/sandbox.ts
CHANGED
|
@@ -4,22 +4,64 @@ import vm from 'node:vm'
|
|
|
4
4
|
const FORBIDDEN = new Set(['Date.now', 'Math.random', 'crypto.randomUUID'])
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
|
|
7
|
+
* Wrap a host function so `.constructor` / `.prototype` / `__proto__` access
|
|
8
|
+
* cannot reach the host `Function` constructor (blocks
|
|
9
|
+
* `fn.constructor('return process')()` and friends).
|
|
10
|
+
*/
|
|
11
|
+
function sealFunction<T extends (...args: never[]) => unknown>(fn: T): T {
|
|
12
|
+
return new Proxy(fn, {
|
|
13
|
+
get(target, prop) {
|
|
14
|
+
if (prop === 'constructor' || prop === 'prototype' || prop === '__proto__') {
|
|
15
|
+
return undefined
|
|
16
|
+
}
|
|
17
|
+
return Reflect.get(target, prop)
|
|
18
|
+
},
|
|
19
|
+
apply(target, thisArg, argArray) {
|
|
20
|
+
return Reflect.apply(target, thisArg, argArray)
|
|
21
|
+
},
|
|
22
|
+
}) as T
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Best-effort seal a value: freeze objects and wrap function properties so
|
|
27
|
+
* their constructor chain can't reach host globals.
|
|
28
|
+
*/
|
|
29
|
+
export function sealValue(value: unknown, depth = 0): unknown {
|
|
30
|
+
if (depth > 4) return value
|
|
31
|
+
if (typeof value === 'function') {
|
|
32
|
+
return sealFunction(value as (...args: never[]) => unknown)
|
|
33
|
+
}
|
|
34
|
+
if (value && typeof value === 'object') {
|
|
35
|
+
for (const key of Object.getOwnPropertyNames(value)) {
|
|
36
|
+
const child = (value as Record<string, unknown>)[key]
|
|
37
|
+
if (typeof child === 'function') {
|
|
38
|
+
;(value as Record<string, unknown>)[key] = sealFunction(
|
|
39
|
+
child as (...args: never[]) => unknown,
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return Object.freeze(value)
|
|
44
|
+
}
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create a VM context for workflow script execution.
|
|
11
50
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
51
|
+
* SECURITY: node:vm is NOT a security boundary. Workflow scripts are
|
|
52
|
+
* model-authored code running in the same process — the model already has
|
|
53
|
+
* Bash access, so this context does NOT isolate the script from the host.
|
|
54
|
+
* Its purpose is determinism (blocking Date.now/Math.random/randomUUID for
|
|
55
|
+
* reproducible replay) plus best-effort blocking of common escape vectors.
|
|
56
|
+
* Do not treat this as a sandbox that can safely run untrusted code.
|
|
15
57
|
*/
|
|
16
58
|
export function createSandbox(
|
|
17
59
|
args: unknown,
|
|
18
60
|
budget: { total: number | null; spent(): number; remaining(): number },
|
|
19
61
|
): vm.Context {
|
|
20
62
|
const sandboxObj: Record<string, unknown> = {
|
|
21
|
-
args,
|
|
22
|
-
budget,
|
|
63
|
+
args: sealValue(args),
|
|
64
|
+
budget: sealValue(budget),
|
|
23
65
|
console: {
|
|
24
66
|
log: (..._a: unknown[]) => {}, // no-op in sandbox
|
|
25
67
|
error: (..._a: unknown[]) => {},
|
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* P1.1: Compaction Progress Tracker
|
|
3
|
-
*
|
|
4
|
-
* Provides user-visible progress feedback during context compaction:
|
|
5
|
-
* - Phase indicator (which compaction layer is active)
|
|
6
|
-
* - Message count (how many messages being processed)
|
|
7
|
-
* - Retry countdown for API-based summarization
|
|
8
|
-
* - Stuck detection hints
|
|
9
|
-
*
|
|
10
|
-
* Designed as an event emitter that the UI layer subscribes to.
|
|
11
|
-
* Falls back to stderr output when no UI subscriber is registered.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
// ── Types ──
|
|
15
|
-
|
|
16
|
-
export type CompactionPhase =
|
|
17
|
-
'microcompact' | 'snip' | 'reactive-compact' | 'emergency-drain' | 'complete'
|
|
18
|
-
|
|
19
|
-
export interface CompactionStatus {
|
|
20
|
-
phase: CompactionPhase
|
|
21
|
-
/** Human-readable description of current action */
|
|
22
|
-
message: string
|
|
23
|
-
/** 0-100 percentage, or -1 if indeterminate */
|
|
24
|
-
percent: number
|
|
25
|
-
/** Total messages before compaction */
|
|
26
|
-
messagesBefore: number
|
|
27
|
-
/** Messages remaining after compaction */
|
|
28
|
-
messagesAfter: number
|
|
29
|
-
/** Tokens before compaction */
|
|
30
|
-
tokensBefore: number
|
|
31
|
-
/** Tokens after compaction */
|
|
32
|
-
tokensAfter: number
|
|
33
|
-
/** Elapsed ms since compaction started */
|
|
34
|
-
elapsedMs: number
|
|
35
|
-
/** Retry count for summarization API calls */
|
|
36
|
-
retryCount: number
|
|
37
|
-
/** Whether a retry is in progress */
|
|
38
|
-
retrying: boolean
|
|
39
|
-
/** Whether the compaction appears stuck (no progress for >30s) */
|
|
40
|
-
stuck: boolean
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export type ProgressCallback = (status: CompactionStatus) => void
|
|
44
|
-
|
|
45
|
-
// ── Constants ──
|
|
46
|
-
|
|
47
|
-
/** Timeout in ms after which we consider compaction "stuck" */
|
|
48
|
-
const STUCK_TIMEOUT_MS = 30_000
|
|
49
|
-
|
|
50
|
-
/** Max retries for summarizer API calls */
|
|
51
|
-
const MAX_SUMMARIZER_RETRIES = 3
|
|
52
|
-
|
|
53
|
-
/** Retry delay in ms between summarizer attempts */
|
|
54
|
-
const RETRY_DELAY_MS = 2_000
|
|
55
|
-
|
|
56
|
-
// ── Tracker ──
|
|
57
|
-
|
|
58
|
-
export class CompactionProgressTracker {
|
|
59
|
-
private callback: ProgressCallback | null = null
|
|
60
|
-
private status: CompactionStatus
|
|
61
|
-
private startTime = 0
|
|
62
|
-
private lastProgressTime = 0
|
|
63
|
-
|
|
64
|
-
constructor(messagesBefore: number, tokensBefore: number) {
|
|
65
|
-
this.status = {
|
|
66
|
-
phase: 'microcompact',
|
|
67
|
-
message: 'Starting compaction...',
|
|
68
|
-
percent: 0,
|
|
69
|
-
messagesBefore,
|
|
70
|
-
messagesAfter: messagesBefore,
|
|
71
|
-
tokensBefore,
|
|
72
|
-
tokensAfter: tokensBefore,
|
|
73
|
-
elapsedMs: 0,
|
|
74
|
-
retryCount: 0,
|
|
75
|
-
retrying: false,
|
|
76
|
-
stuck: false,
|
|
77
|
-
}
|
|
78
|
-
this.startTime = Date.now()
|
|
79
|
-
this.lastProgressTime = Date.now()
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Register a UI callback for progress updates. */
|
|
83
|
-
onProgress(cb: ProgressCallback): void {
|
|
84
|
-
this.callback = cb
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Update phase with new values. */
|
|
88
|
-
update(partial: {
|
|
89
|
-
phase?: CompactionPhase
|
|
90
|
-
message?: string
|
|
91
|
-
percent?: number
|
|
92
|
-
messagesAfter?: number
|
|
93
|
-
tokensAfter?: number
|
|
94
|
-
retrying?: boolean
|
|
95
|
-
}): void {
|
|
96
|
-
if (partial.phase !== undefined) this.status.phase = partial.phase
|
|
97
|
-
if (partial.message !== undefined) this.status.message = partial.message
|
|
98
|
-
if (partial.percent !== undefined) this.status.percent = partial.percent
|
|
99
|
-
if (partial.messagesAfter !== undefined) this.status.messagesAfter = partial.messagesAfter
|
|
100
|
-
if (partial.tokensAfter !== undefined) this.status.tokensAfter = partial.tokensAfter
|
|
101
|
-
if (partial.retrying !== undefined) this.status.retrying = partial.retrying
|
|
102
|
-
|
|
103
|
-
this.status.elapsedMs = Date.now() - this.startTime
|
|
104
|
-
|
|
105
|
-
// Check for stuck detection
|
|
106
|
-
if (this.status.phase !== 'complete') {
|
|
107
|
-
const timeSinceProgress = Date.now() - this.lastProgressTime
|
|
108
|
-
this.status.stuck = timeSinceProgress > STUCK_TIMEOUT_MS
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
this.emit()
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Mark current retry attempt. */
|
|
115
|
-
onRetry(attempt: number): void {
|
|
116
|
-
this.status.retryCount = attempt
|
|
117
|
-
this.status.retrying = true
|
|
118
|
-
this.emit()
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** Mark retry as complete (success or giving up). */
|
|
122
|
-
onRetryComplete(): void {
|
|
123
|
-
this.status.retrying = false
|
|
124
|
-
this.lastProgressTime = Date.now()
|
|
125
|
-
this.status.stuck = false
|
|
126
|
-
this.emit()
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Mark compaction as complete. */
|
|
130
|
-
complete(messagesAfter: number, tokensAfter: number): void {
|
|
131
|
-
this.status.phase = 'complete'
|
|
132
|
-
this.status.percent = 100
|
|
133
|
-
this.status.messagesAfter = messagesAfter
|
|
134
|
-
this.status.tokensAfter = tokensAfter
|
|
135
|
-
this.status.message = `Compaction complete: ${this.status.messagesBefore} → ${messagesAfter} messages`
|
|
136
|
-
this.status.elapsedMs = Date.now() - this.startTime
|
|
137
|
-
this.emit()
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Get current status. */
|
|
141
|
-
getStatus(): CompactionStatus {
|
|
142
|
-
return { ...this.status }
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Generate a compact summary string for CLI display. */
|
|
146
|
-
getSummary(): string {
|
|
147
|
-
const s = this.status
|
|
148
|
-
const elapsed = (s.elapsedMs / 1000).toFixed(1)
|
|
149
|
-
const stuckHint = s.stuck ? ' (stalled — press Esc to cancel)' : ''
|
|
150
|
-
const retryHint = s.retrying ? ` [retry ${s.retryCount}/${MAX_SUMMARIZER_RETRIES}...]` : ''
|
|
151
|
-
const pct = s.percent >= 0 ? ` ${s.percent}%` : ''
|
|
152
|
-
return `🔄 [${s.phase}]${pct} ${s.message}${retryHint}${stuckHint} (${elapsed}s)`
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
private emit(): void {
|
|
156
|
-
if (this.callback) {
|
|
157
|
-
try {
|
|
158
|
-
this.callback(this.getStatus())
|
|
159
|
-
} catch {
|
|
160
|
-
// UI callback failure shouldn't block compaction
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// ── Static helpers ──
|
|
166
|
-
|
|
167
|
-
/** Retry a summarizer call with backoff and progress tracking. */
|
|
168
|
-
static async retrySummarizer(
|
|
169
|
-
summarizer: () => Promise<string>,
|
|
170
|
-
tracker: CompactionProgressTracker,
|
|
171
|
-
messagesToSummarize: number,
|
|
172
|
-
): Promise<string> {
|
|
173
|
-
let lastError: Error | undefined
|
|
174
|
-
|
|
175
|
-
for (let attempt = 1; attempt <= MAX_SUMMARIZER_RETRIES; attempt++) {
|
|
176
|
-
try {
|
|
177
|
-
tracker.update({
|
|
178
|
-
message: `Summarizing ${messagesToSummarize} messages${attempt > 1 ? ` (attempt ${attempt}/${MAX_SUMMARIZER_RETRIES})` : ''}...`,
|
|
179
|
-
percent: 30 + attempt * 20,
|
|
180
|
-
retrying: attempt > 1,
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
const result = await summarizer()
|
|
184
|
-
tracker.onRetryComplete()
|
|
185
|
-
return result
|
|
186
|
-
} catch (err) {
|
|
187
|
-
lastError = err instanceof Error ? err : new Error(String(err))
|
|
188
|
-
|
|
189
|
-
if (attempt < MAX_SUMMARIZER_RETRIES) {
|
|
190
|
-
tracker.onRetry(attempt)
|
|
191
|
-
// Wait before retry
|
|
192
|
-
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS))
|
|
193
|
-
tracker.onRetryComplete()
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
tracker.update({
|
|
199
|
-
message: `Summarization failed after ${MAX_SUMMARIZER_RETRIES} attempts — falling back to truncation`,
|
|
200
|
-
percent: 60,
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
throw lastError ?? new Error('Summarization failed')
|
|
204
|
-
}
|
|
205
|
-
}
|