@namzu/sdk 13.0.0 → 14.0.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/CHANGELOG.md +208 -0
- package/dist/agents/runAgent.d.ts +9 -0
- package/dist/agents/runAgent.d.ts.map +1 -1
- package/dist/agents/runAgent.js.map +1 -1
- package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.d.ts +2 -0
- package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.d.ts.map +1 -0
- package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.js +101 -0
- package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.js.map +1 -0
- package/dist/connector/mcp/http-sse.d.ts +10 -0
- package/dist/connector/mcp/http-sse.d.ts.map +1 -1
- package/dist/connector/mcp/http-sse.js +16 -0
- package/dist/connector/mcp/http-sse.js.map +1 -1
- package/dist/connector/mcp/stdio.d.ts +12 -0
- package/dist/connector/mcp/stdio.d.ts.map +1 -1
- package/dist/connector/mcp/stdio.js +33 -1
- package/dist/connector/mcp/stdio.js.map +1 -1
- package/dist/connector/mcp/streamable-http.d.ts +2 -0
- package/dist/connector/mcp/streamable-http.d.ts.map +1 -1
- package/dist/connector/mcp/streamable-http.js +13 -1
- package/dist/connector/mcp/streamable-http.js.map +1 -1
- package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.d.ts +2 -0
- package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.d.ts.map +1 -0
- package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.js +73 -0
- package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.js.map +1 -0
- package/dist/contracts/schemas.d.ts +15 -0
- package/dist/contracts/schemas.d.ts.map +1 -1
- package/dist/contracts/schemas.js +18 -1
- package/dist/contracts/schemas.js.map +1 -1
- package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.d.ts +2 -0
- package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.d.ts.map +1 -0
- package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.js +159 -0
- package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.js.map +1 -0
- package/dist/manager/agent/lifecycle.d.ts.map +1 -1
- package/dist/manager/agent/lifecycle.js +39 -4
- package/dist/manager/agent/lifecycle.js.map +1 -1
- package/dist/manager/index.d.ts +2 -0
- package/dist/manager/index.d.ts.map +1 -1
- package/dist/manager/index.js +1 -0
- package/dist/manager/index.js.map +1 -1
- package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.d.ts +2 -0
- package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.d.ts.map +1 -0
- package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.js +208 -0
- package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.js.map +1 -0
- package/dist/manager/project/lifecycle.d.ts +66 -0
- package/dist/manager/project/lifecycle.d.ts.map +1 -0
- package/dist/manager/project/lifecycle.js +125 -0
- package/dist/manager/project/lifecycle.js.map +1 -0
- package/dist/public-runtime.d.ts +2 -2
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +7 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/session/errors.d.ts +71 -1
- package/dist/session/errors.d.ts.map +1 -1
- package/dist/session/errors.js +50 -0
- package/dist/session/errors.js.map +1 -1
- package/dist/session/handoff/broadcast.d.ts.map +1 -1
- package/dist/session/handoff/broadcast.js +4 -5
- package/dist/session/handoff/broadcast.js.map +1 -1
- package/dist/session/handoff/single.d.ts.map +1 -1
- package/dist/session/handoff/single.js +5 -4
- package/dist/session/handoff/single.js.map +1 -1
- package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.d.ts +2 -0
- package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.d.ts.map +1 -0
- package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.js +75 -0
- package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.js.map +1 -0
- package/dist/session/migration/filesystem.d.ts.map +1 -1
- package/dist/session/migration/filesystem.js +24 -0
- package/dist/session/migration/filesystem.js.map +1 -1
- package/dist/store/session/__tests__/a-workspace-can-be-configured.test.d.ts +2 -0
- package/dist/store/session/__tests__/a-workspace-can-be-configured.test.d.ts.map +1 -0
- package/dist/store/session/__tests__/a-workspace-can-be-configured.test.js +149 -0
- package/dist/store/session/__tests__/a-workspace-can-be-configured.test.js.map +1 -0
- package/dist/store/session/disk.d.ts +6 -2
- package/dist/store/session/disk.d.ts.map +1 -1
- package/dist/store/session/disk.js +120 -3
- package/dist/store/session/disk.js.map +1 -1
- package/dist/store/session/memory.d.ts +6 -2
- package/dist/store/session/memory.d.ts.map +1 -1
- package/dist/store/session/memory.js +78 -3
- package/dist/store/session/memory.js.map +1 -1
- package/dist/tools/coordinator/agent.d.ts.map +1 -1
- package/dist/tools/coordinator/agent.js +7 -0
- package/dist/tools/coordinator/agent.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +6 -0
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/types/agent/base.d.ts +22 -0
- package/dist/types/agent/base.d.ts.map +1 -1
- package/dist/types/project/entity.d.ts +17 -0
- package/dist/types/project/entity.d.ts.map +1 -1
- package/dist/types/session/store.d.ts +80 -1
- package/dist/types/session/store.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/agents/runAgent.ts +9 -0
- package/src/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.ts +127 -0
- package/src/connector/mcp/http-sse.ts +17 -0
- package/src/connector/mcp/stdio.ts +34 -1
- package/src/connector/mcp/streamable-http.ts +15 -1
- package/src/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.ts +84 -0
- package/src/contracts/schemas.ts +18 -1
- package/src/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.ts +196 -0
- package/src/manager/agent/lifecycle.ts +42 -6
- package/src/manager/index.ts +3 -0
- package/src/manager/project/__tests__/a-closed-workspace-takes-no-work.test.ts +268 -0
- package/src/manager/project/lifecycle.ts +160 -0
- package/src/public-runtime.ts +9 -0
- package/src/session/errors.ts +85 -1
- package/src/session/handoff/broadcast.ts +4 -5
- package/src/session/handoff/single.ts +5 -4
- package/src/session/migration/__tests__/a-migrated-project-id-is-an-id.test.ts +92 -0
- package/src/session/migration/filesystem.ts +28 -0
- package/src/store/session/__tests__/a-workspace-can-be-configured.test.ts +210 -0
- package/src/store/session/disk.ts +142 -4
- package/src/store/session/memory.ts +95 -4
- package/src/tools/coordinator/agent.ts +7 -0
- package/src/tools/coordinator/index.ts +6 -0
- package/src/types/agent/base.ts +23 -0
- package/src/types/project/entity.ts +18 -0
- package/src/types/session/store.ts +95 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
DefaultFilesystemMigrator,
|
|
8
|
+
NOOP_FILESYSTEM_MIGRATION_SINK,
|
|
9
|
+
} from '../../session/migration/filesystem.js'
|
|
10
|
+
import { generateProjectId } from '../../utils/id.js'
|
|
11
|
+
import { ProjectIdSchema } from '../schemas.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `ProjectIdSchema` was `/^prj_[a-z0-9]+$/` while the v0.2.0 filesystem
|
|
15
|
+
* migration minted `prj_legacy_<suffix>`. So the SDK's own public validator
|
|
16
|
+
* rejected ids the SDK itself had written to disk: a host that validated an
|
|
17
|
+
* inbound project id — the reason the schema is exported at all — refused
|
|
18
|
+
* every project it had migrated, with "Invalid project ID format" and no hint
|
|
19
|
+
* that the id came from the SDK.
|
|
20
|
+
*
|
|
21
|
+
* Both minters are driven here rather than restated. A test that spells out
|
|
22
|
+
* `prj_legacy_abc` as a literal would still pass if the migration changed its
|
|
23
|
+
* shape tomorrow; running the migration means the two cannot drift apart
|
|
24
|
+
* without this failing.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const dirs: string[] = []
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
await Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true })))
|
|
30
|
+
dirs.length = 0
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('every project id the SDK mints passes the schema the SDK exports', () => {
|
|
34
|
+
it('accepts what the id generator produces', () => {
|
|
35
|
+
// 200 draws, because the generator is random over [0-9a-z] and one
|
|
36
|
+
// sample proves nothing about the alphabet it can reach.
|
|
37
|
+
for (let i = 0; i < 200; i++) {
|
|
38
|
+
const id = generateProjectId()
|
|
39
|
+
const parsed = ProjectIdSchema.safeParse(id)
|
|
40
|
+
expect({ id, ok: parsed.success }).toEqual({ id, ok: true })
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('accepts what the filesystem migration produces', async () => {
|
|
45
|
+
const root = await mkdtemp(join(tmpdir(), 'namzu-idschema-'))
|
|
46
|
+
dirs.push(root)
|
|
47
|
+
const runDir = join(root, 'threads', 'thd_a1b2c3d4e5f6', 'runs', 'run_seed')
|
|
48
|
+
await mkdir(runDir, { recursive: true })
|
|
49
|
+
await writeFile(join(runDir, 'run.json'), JSON.stringify({ id: 'run_seed' }), 'utf-8')
|
|
50
|
+
|
|
51
|
+
const result = await new DefaultFilesystemMigrator(NOOP_FILESYSTEM_MIGRATION_SINK).migrate(root)
|
|
52
|
+
|
|
53
|
+
expect(result.kind).toBe('migrated')
|
|
54
|
+
expect(result.migratedThreads).toHaveLength(1)
|
|
55
|
+
for (const { newProjectId } of result.migratedThreads) {
|
|
56
|
+
const parsed = ProjectIdSchema.safeParse(newProjectId)
|
|
57
|
+
expect({ newProjectId, ok: parsed.success }).toEqual({ newProjectId, ok: true })
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('still refuses what no minter produces, because the id is also a directory name', () => {
|
|
62
|
+
// Widening to accept the legacy form must not widen to accept a path.
|
|
63
|
+
// Everything here would be joined onto the store root if it got through.
|
|
64
|
+
const refused = [
|
|
65
|
+
'prj_../../etc',
|
|
66
|
+
'prj_..',
|
|
67
|
+
'prj_a/b',
|
|
68
|
+
'prj_a\\b',
|
|
69
|
+
'prj_',
|
|
70
|
+
'prj_legacy_',
|
|
71
|
+
'prj_ABC',
|
|
72
|
+
'prj_a-b',
|
|
73
|
+
'prj_a b',
|
|
74
|
+
'proj_abc',
|
|
75
|
+
'thd_abc',
|
|
76
|
+
'',
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
for (const candidate of refused) {
|
|
80
|
+
const parsed = ProjectIdSchema.safeParse(candidate)
|
|
81
|
+
expect({ candidate, ok: parsed.success }).toEqual({ candidate, ok: false })
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
})
|
package/src/contracts/schemas.ts
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* The two shapes the SDK mints, and nothing else.
|
|
5
|
+
*
|
|
6
|
+
* `prj_<12 lowercase alphanumerics>` comes from `generateProjectId()`.
|
|
7
|
+
* `prj_legacy_<suffix>` comes from the v0.2.0 filesystem migration, which
|
|
8
|
+
* synthesises a project per legacy `thd_*` folder. The `legacy_` segment is
|
|
9
|
+
* spelled out rather than covered by a permissive character class, because a
|
|
10
|
+
* project id is also a directory name: everything this accepts is joined to a
|
|
11
|
+
* path, so the set it accepts is a containment boundary, not a formatting
|
|
12
|
+
* preference.
|
|
13
|
+
*
|
|
14
|
+
* A host that supplies its own `SessionStore` and mints its own ids should
|
|
15
|
+
* validate with its own schema — `ProjectId` is `prj_${string}` at the type
|
|
16
|
+
* level and this schema is deliberately narrower.
|
|
17
|
+
*/
|
|
18
|
+
export const ProjectIdSchema = z
|
|
19
|
+
.string()
|
|
20
|
+
.regex(/^prj_(legacy_)?[a-z0-9]+$/, 'Invalid project ID format')
|
|
4
21
|
export const RunIdSchema = z.string().regex(/^run_[a-z0-9]+$/, 'Invalid run ID format')
|
|
5
22
|
export const MessageIdSchema = z.string().regex(/^msg_[a-z0-9]+$/, 'Invalid message ID format')
|
|
6
23
|
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { EMPTY_TOKEN_USAGE } from '../../../constants/limits.js'
|
|
4
|
+
import { AgentRegistry } from '../../../registry/agent/definitions.js'
|
|
5
|
+
import { DefaultCapacityValidator } from '../../../session/handoff/capacity.js'
|
|
6
|
+
import { SessionSummaryMaterializer } from '../../../session/summary/materialize.js'
|
|
7
|
+
import { WorkspaceBackendRegistry } from '../../../session/workspace/registry.js'
|
|
8
|
+
import { InMemorySessionStore } from '../../../store/session/memory.js'
|
|
9
|
+
import { InMemoryThreadStore } from '../../../store/thread/memory.js'
|
|
10
|
+
import type { BaseAgentConfig, BaseAgentResult } from '../../../types/agent/base.js'
|
|
11
|
+
import type { Agent } from '../../../types/agent/core.js'
|
|
12
|
+
import type { AgentDefinition } from '../../../types/agent/factory.js'
|
|
13
|
+
import type { AgentTaskContext } from '../../../types/agent/task.js'
|
|
14
|
+
import type { AgentId, TenantId } from '../../../types/ids/index.js'
|
|
15
|
+
import type { SummaryId } from '../../../types/session/ids.js'
|
|
16
|
+
import { ZERO_COST } from '../../../utils/cost.js'
|
|
17
|
+
import { ThreadManager } from '../../thread/lifecycle.js'
|
|
18
|
+
import { AgentManager } from '../lifecycle.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A delegate registered the normal way inherited no environment at all.
|
|
22
|
+
*
|
|
23
|
+
* `AgentManager` builds a child's config on two branches. The bare-config
|
|
24
|
+
* branch — taken only when a definition has NO `configBuilder` — has always
|
|
25
|
+
* carried `env`. The `configBuilder` branch, which is what a host registering
|
|
26
|
+
* a real agent actually uses, never stamped it. So a run given an environment
|
|
27
|
+
* handed its delegates none of it.
|
|
28
|
+
*
|
|
29
|
+
* This is the third field to go the same way: `parentSpan` and `resumeHandler`
|
|
30
|
+
* are both stamped after the builder returns, each with a comment saying the
|
|
31
|
+
* builder is written by whoever registered the agent and cannot be expected to
|
|
32
|
+
* forward something it was never told about. `env` was missed, and it went
|
|
33
|
+
* unnoticed because a missing environment does not fail — the child just runs
|
|
34
|
+
* somewhere else.
|
|
35
|
+
*
|
|
36
|
+
* These drive the real `AgentManager` rather than re-implementing the merge.
|
|
37
|
+
* A test that restates the logic proves the logic agrees with itself; what has
|
|
38
|
+
* to hold is that the manager applies it.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const TENANT = 'tnt_env' as TenantId
|
|
42
|
+
|
|
43
|
+
/** Records the config it is run with, so the assertion is on what shipped. */
|
|
44
|
+
function recordingAgent(seen: { config?: BaseAgentConfig }) {
|
|
45
|
+
return {
|
|
46
|
+
type: 'reactive',
|
|
47
|
+
metadata: {
|
|
48
|
+
id: 'worker',
|
|
49
|
+
name: 'worker',
|
|
50
|
+
version: '1.0.0',
|
|
51
|
+
category: 'general',
|
|
52
|
+
description: 'records its config',
|
|
53
|
+
type: 'reactive',
|
|
54
|
+
capabilities: {},
|
|
55
|
+
},
|
|
56
|
+
async run(_input: unknown, config: BaseAgentConfig): Promise<BaseAgentResult> {
|
|
57
|
+
seen.config = config
|
|
58
|
+
return {
|
|
59
|
+
runId: 'run_child',
|
|
60
|
+
status: 'completed',
|
|
61
|
+
result: 'ok',
|
|
62
|
+
usage: { ...EMPTY_TOKEN_USAGE },
|
|
63
|
+
cost: { ...ZERO_COST },
|
|
64
|
+
iterations: 1,
|
|
65
|
+
durationMs: 0,
|
|
66
|
+
messages: [],
|
|
67
|
+
} as BaseAgentResult
|
|
68
|
+
},
|
|
69
|
+
async cancel() {},
|
|
70
|
+
getCapabilities() {
|
|
71
|
+
return {} as never
|
|
72
|
+
},
|
|
73
|
+
} as unknown as Agent<BaseAgentConfig, BaseAgentResult>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A definition WITH a configBuilder — the branch that dropped `env`. */
|
|
77
|
+
function definitionWithBuilder(
|
|
78
|
+
agent: Agent<BaseAgentConfig, BaseAgentResult>,
|
|
79
|
+
builderEnv?: Record<string, string>,
|
|
80
|
+
): AgentDefinition {
|
|
81
|
+
return {
|
|
82
|
+
info: {
|
|
83
|
+
id: 'worker',
|
|
84
|
+
name: 'worker',
|
|
85
|
+
version: '1.0.0',
|
|
86
|
+
category: 'general',
|
|
87
|
+
description: 'a worker',
|
|
88
|
+
tools: [],
|
|
89
|
+
defaults: { model: 'test', tokenBudget: 1_000 },
|
|
90
|
+
},
|
|
91
|
+
typedAgent: agent,
|
|
92
|
+
configBuilder: () =>
|
|
93
|
+
({
|
|
94
|
+
model: 'test',
|
|
95
|
+
tokenBudget: 1_000,
|
|
96
|
+
timeoutMs: 10_000,
|
|
97
|
+
...(builderEnv ? { env: builderEnv } : {}),
|
|
98
|
+
}) as BaseAgentConfig,
|
|
99
|
+
} as AgentDefinition
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function spawnWith(options: {
|
|
103
|
+
builderEnv?: Record<string, string>
|
|
104
|
+
overrideEnv?: Record<string, string>
|
|
105
|
+
}): Promise<BaseAgentConfig | undefined> {
|
|
106
|
+
const seen: { config?: BaseAgentConfig } = {}
|
|
107
|
+
const store = new InMemorySessionStore()
|
|
108
|
+
const threadStore = new InMemoryThreadStore()
|
|
109
|
+
const project = await store.createProject({ tenantId: TENANT, name: 'p' }, TENANT)
|
|
110
|
+
const thread = await threadStore.createThread({ projectId: project.id, title: 't' }, TENANT)
|
|
111
|
+
const parentActor = { kind: 'agent', agentId: 'sup' as AgentId, tenantId: TENANT } as const
|
|
112
|
+
const parentSession = await store.createSession(
|
|
113
|
+
{ threadId: thread.id, projectId: project.id, currentActor: parentActor },
|
|
114
|
+
TENANT,
|
|
115
|
+
)
|
|
116
|
+
await store.updateSession({ ...parentSession, status: 'active' }, TENANT)
|
|
117
|
+
|
|
118
|
+
const registry = new AgentRegistry()
|
|
119
|
+
registry.register(definitionWithBuilder(recordingAgent(seen), options.builderEnv))
|
|
120
|
+
|
|
121
|
+
let n = 0
|
|
122
|
+
const manager = new AgentManager(registry, undefined, {
|
|
123
|
+
sessionStore: store,
|
|
124
|
+
threadManager: new ThreadManager({ threadStore, sessionStore: store }),
|
|
125
|
+
workspaceRegistry: new WorkspaceBackendRegistry(),
|
|
126
|
+
capacity: new DefaultCapacityValidator(store),
|
|
127
|
+
summaryMaterializer: new SessionSummaryMaterializer({
|
|
128
|
+
store,
|
|
129
|
+
generateSummaryId: () => `sum_${++n}` as SummaryId,
|
|
130
|
+
}),
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const context: AgentTaskContext = {
|
|
134
|
+
parentRunId: 'run_parent' as never,
|
|
135
|
+
parentAgentId: 'sup',
|
|
136
|
+
parentAbortController: new AbortController(),
|
|
137
|
+
depth: 0,
|
|
138
|
+
budgetTracker: { total: 100_000, remaining: 100_000 },
|
|
139
|
+
tenantId: TENANT,
|
|
140
|
+
threadId: thread.id,
|
|
141
|
+
sessionId: parentSession.id,
|
|
142
|
+
projectId: project.id,
|
|
143
|
+
parentActor,
|
|
144
|
+
} as AgentTaskContext
|
|
145
|
+
|
|
146
|
+
const task = await manager.sendMessage(
|
|
147
|
+
{
|
|
148
|
+
agentId: 'worker',
|
|
149
|
+
input: { messages: [], workingDirectory: '/tmp' } as never,
|
|
150
|
+
parentSessionId: parentSession.id,
|
|
151
|
+
tenantId: TENANT,
|
|
152
|
+
projectId: project.id,
|
|
153
|
+
parentActor,
|
|
154
|
+
...(options.overrideEnv ? { configOverrides: { env: options.overrideEnv } } : {}),
|
|
155
|
+
} as never,
|
|
156
|
+
context,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
await manager.waitForCompletion(task.taskId)
|
|
160
|
+
return seen.config
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
describe('an environment survives a configBuilder that never heard of it', () => {
|
|
164
|
+
it('reaches a child whose builder sets none', async () => {
|
|
165
|
+
const config = await spawnWith({ overrideEnv: { API_BASE: 'https://staging' } })
|
|
166
|
+
|
|
167
|
+
expect(config?.env).toEqual({ API_BASE: 'https://staging' })
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('keeps the builder keys the caller did not restate', async () => {
|
|
171
|
+
// Why this is a merge and not an assignment: `configOverrides` is a
|
|
172
|
+
// Partial, so replacing the map would drop everything the builder set,
|
|
173
|
+
// and a caller overriding one variable is not saying the rest should
|
|
174
|
+
// vanish.
|
|
175
|
+
const config = await spawnWith({
|
|
176
|
+
builderEnv: { REGION: 'eu', TIER: 'free' },
|
|
177
|
+
overrideEnv: { TIER: 'paid' },
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
expect(config?.env).toEqual({ REGION: 'eu', TIER: 'paid' })
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('leaves the builder environment alone when nothing overrides it', async () => {
|
|
184
|
+
const config = await spawnWith({ builderEnv: { REGION: 'eu' } })
|
|
185
|
+
|
|
186
|
+
expect(config?.env).toEqual({ REGION: 'eu' })
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('gives a child with no environment undefined rather than an empty map', async () => {
|
|
190
|
+
// An agent that never had an environment must not gain an empty object
|
|
191
|
+
// that every reader then has to tell apart from a real one.
|
|
192
|
+
const config = await spawnWith({})
|
|
193
|
+
|
|
194
|
+
expect(config?.env).toBeUndefined()
|
|
195
|
+
})
|
|
196
|
+
})
|
|
@@ -36,6 +36,7 @@ import { ZERO_COST } from '../../utils/cost.js'
|
|
|
36
36
|
import { toErrorMessage } from '../../utils/error.js'
|
|
37
37
|
import { generateTaskId } from '../../utils/id.js'
|
|
38
38
|
import { type Logger, getRootLogger } from '../../utils/logger.js'
|
|
39
|
+
import { requireOpenProject } from '../project/lifecycle.js'
|
|
39
40
|
import type { ThreadManager } from '../thread/lifecycle.js'
|
|
40
41
|
|
|
41
42
|
/**
|
|
@@ -82,6 +83,25 @@ interface ChildSpawnRecord {
|
|
|
82
83
|
workspaceRef?: WorkspaceRef
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Combine a child's own environment with what its parent passed down.
|
|
88
|
+
*
|
|
89
|
+
* Per key, override winning — not whole-value replacement, which would drop
|
|
90
|
+
* every key a `configBuilder` set and the caller did not happen to restate.
|
|
91
|
+
* `configOverrides` is a `Partial`, so replacement reads as "the caller
|
|
92
|
+
* supplied an environment" when what they supplied was one variable.
|
|
93
|
+
*
|
|
94
|
+
* Returns `undefined` when both sides are empty, so an agent that never had an
|
|
95
|
+
* environment does not gain an empty object it then has to be checked for.
|
|
96
|
+
*/
|
|
97
|
+
function mergeEnv(
|
|
98
|
+
base: Readonly<Record<string, string>> | undefined,
|
|
99
|
+
override: Readonly<Record<string, string>> | undefined,
|
|
100
|
+
): Record<string, string> | undefined {
|
|
101
|
+
if (!base && !override) return undefined
|
|
102
|
+
return { ...base, ...override }
|
|
103
|
+
}
|
|
104
|
+
|
|
85
105
|
export class AgentManager {
|
|
86
106
|
private registry: AgentRegistry
|
|
87
107
|
private instances: Map<TaskId, AgentTask> = new Map()
|
|
@@ -289,6 +309,23 @@ export class AgentManager {
|
|
|
289
309
|
// covered the top-level run and nothing it delegated.
|
|
290
310
|
const inheritedHandler = options.configOverrides?.resumeHandler ?? context.resumeHandler
|
|
291
311
|
if (inheritedHandler) childConfig.resumeHandler = inheritedHandler
|
|
312
|
+
|
|
313
|
+
// And the environment, for the third time and the same reason.
|
|
314
|
+
//
|
|
315
|
+
// The bare-config branch below has always carried `env`; this one
|
|
316
|
+
// never did, so a delegate registered WITH a `configBuilder` — the
|
|
317
|
+
// normal way, and what every host in this repo does — silently ran
|
|
318
|
+
// with none of the environment its parent had been given. The
|
|
319
|
+
// builder is written by whoever registered the agent and cannot be
|
|
320
|
+
// expected to forward a field it was never told about, which is
|
|
321
|
+
// exactly why `parentSpan` and `resumeHandler` are stamped here too.
|
|
322
|
+
//
|
|
323
|
+
// Merged per key rather than replaced. `configOverrides` is a
|
|
324
|
+
// `Partial`, so assigning the whole map would drop every key the
|
|
325
|
+
// builder set and the caller did not restate — the override wins per
|
|
326
|
+
// key, the same direction it already wins for `model` and `effort`.
|
|
327
|
+
const inheritedEnv = mergeEnv(childConfig.env, options.configOverrides?.env)
|
|
328
|
+
if (inheritedEnv) childConfig.env = inheritedEnv
|
|
292
329
|
} else {
|
|
293
330
|
this.log.warn('No configBuilder, using bare config', {
|
|
294
331
|
agentId: options.agentId,
|
|
@@ -557,12 +594,11 @@ export class AgentManager {
|
|
|
557
594
|
)
|
|
558
595
|
}
|
|
559
596
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
}
|
|
597
|
+
// Same read that loads the limits, now also the gate: an archived
|
|
598
|
+
// workspace accepts no new session. It replaces a bare `getProject` +
|
|
599
|
+
// null check rather than adding a round-trip, because a gate that costs
|
|
600
|
+
// something is a gate someone eventually moves.
|
|
601
|
+
const project = await requireOpenProject(store, context.projectId, context.tenantId, 'spawn')
|
|
566
602
|
|
|
567
603
|
// Capacity: depth + width. Depth uses the parent session's ancestry
|
|
568
604
|
// chain; width counts existing direct children of the parent.
|
package/src/manager/index.ts
CHANGED
|
@@ -20,3 +20,6 @@ export { ThreadManager } from './thread/lifecycle.js'
|
|
|
20
20
|
export type { ThreadManagerDeps } from './thread/lifecycle.js'
|
|
21
21
|
|
|
22
22
|
export { AgentManager } from './agent/lifecycle.js'
|
|
23
|
+
|
|
24
|
+
export { ProjectManager, requireOpenProject } from './project/lifecycle.js'
|
|
25
|
+
export type { ProjectManagerDeps } from './project/lifecycle.js'
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { EMPTY_TOKEN_USAGE } from '../../../constants/limits.js'
|
|
7
|
+
import { AgentRegistry } from '../../../registry/agent/definitions.js'
|
|
8
|
+
import {
|
|
9
|
+
ProjectClosedError,
|
|
10
|
+
ProjectNotEmptyError,
|
|
11
|
+
StaleProjectError,
|
|
12
|
+
} from '../../../session/errors.js'
|
|
13
|
+
import { DefaultCapacityValidator } from '../../../session/handoff/capacity.js'
|
|
14
|
+
import { SessionSummaryMaterializer } from '../../../session/summary/materialize.js'
|
|
15
|
+
import { WorkspaceBackendRegistry } from '../../../session/workspace/registry.js'
|
|
16
|
+
import { DiskSessionStore } from '../../../store/session/disk.js'
|
|
17
|
+
import { InMemorySessionStore } from '../../../store/session/memory.js'
|
|
18
|
+
import { InMemoryThreadStore } from '../../../store/thread/memory.js'
|
|
19
|
+
import type { BaseAgentConfig, BaseAgentResult } from '../../../types/agent/base.js'
|
|
20
|
+
import type { Agent } from '../../../types/agent/core.js'
|
|
21
|
+
import type { AgentDefinition } from '../../../types/agent/factory.js'
|
|
22
|
+
import type { AgentTaskContext } from '../../../types/agent/task.js'
|
|
23
|
+
import type { AgentId, TenantId } from '../../../types/ids/index.js'
|
|
24
|
+
import type { SummaryId } from '../../../types/session/ids.js'
|
|
25
|
+
import { ZERO_COST } from '../../../utils/cost.js'
|
|
26
|
+
import { AgentManager } from '../../agent/lifecycle.js'
|
|
27
|
+
import { ThreadManager } from '../../thread/lifecycle.js'
|
|
28
|
+
import { ProjectManager } from '../lifecycle.js'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Archiving a workspace meant nothing to the code.
|
|
32
|
+
*
|
|
33
|
+
* `Thread` carried a status and a gate; `Project` — the thing a tenant owns,
|
|
34
|
+
* configures, gives an environment, and actually closes — carried neither. So
|
|
35
|
+
* a host could archive a workspace and the kernel would keep spawning agents
|
|
36
|
+
* into it, because there was no state for the spawn path to read.
|
|
37
|
+
*
|
|
38
|
+
* The spawn case drives the real `AgentManager` rather than calling the gate
|
|
39
|
+
* directly. A test that calls `requireOpenProject` proves the function throws;
|
|
40
|
+
* what has to hold is that the spawn path reaches it, and the only assertion
|
|
41
|
+
* that cannot pass with the call deleted is one made through the front door.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const TENANT = 'tnt_close' as TenantId
|
|
45
|
+
|
|
46
|
+
function silentAgent(): Agent<BaseAgentConfig, BaseAgentResult> {
|
|
47
|
+
return {
|
|
48
|
+
type: 'reactive',
|
|
49
|
+
metadata: {
|
|
50
|
+
id: 'worker',
|
|
51
|
+
name: 'worker',
|
|
52
|
+
version: '1.0.0',
|
|
53
|
+
category: 'general',
|
|
54
|
+
description: 'does nothing',
|
|
55
|
+
type: 'reactive',
|
|
56
|
+
capabilities: {},
|
|
57
|
+
},
|
|
58
|
+
async run(): Promise<BaseAgentResult> {
|
|
59
|
+
return {
|
|
60
|
+
runId: 'run_child',
|
|
61
|
+
status: 'completed',
|
|
62
|
+
result: 'ok',
|
|
63
|
+
usage: { ...EMPTY_TOKEN_USAGE },
|
|
64
|
+
cost: { ...ZERO_COST },
|
|
65
|
+
iterations: 1,
|
|
66
|
+
durationMs: 0,
|
|
67
|
+
messages: [],
|
|
68
|
+
} as BaseAgentResult
|
|
69
|
+
},
|
|
70
|
+
async cancel() {},
|
|
71
|
+
getCapabilities() {
|
|
72
|
+
return {} as never
|
|
73
|
+
},
|
|
74
|
+
} as unknown as Agent<BaseAgentConfig, BaseAgentResult>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function definition(): AgentDefinition {
|
|
78
|
+
return {
|
|
79
|
+
info: {
|
|
80
|
+
id: 'worker',
|
|
81
|
+
name: 'worker',
|
|
82
|
+
version: '1.0.0',
|
|
83
|
+
category: 'general',
|
|
84
|
+
description: 'a worker',
|
|
85
|
+
tools: [],
|
|
86
|
+
defaults: { model: 'test', tokenBudget: 1_000 },
|
|
87
|
+
},
|
|
88
|
+
typedAgent: silentAgent(),
|
|
89
|
+
} as AgentDefinition
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A parent session in a real workspace, plus the manager that spawns into it. */
|
|
93
|
+
async function harness() {
|
|
94
|
+
const store = new InMemorySessionStore()
|
|
95
|
+
const threadStore = new InMemoryThreadStore()
|
|
96
|
+
const project = await store.createProject({ tenantId: TENANT, name: 'w' }, TENANT)
|
|
97
|
+
const thread = await threadStore.createThread({ projectId: project.id, title: 't' }, TENANT)
|
|
98
|
+
const parentActor = { kind: 'agent', agentId: 'sup' as AgentId, tenantId: TENANT } as const
|
|
99
|
+
const parentSession = await store.createSession(
|
|
100
|
+
{ threadId: thread.id, projectId: project.id, currentActor: parentActor },
|
|
101
|
+
TENANT,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const registry = new AgentRegistry()
|
|
105
|
+
registry.register(definition())
|
|
106
|
+
|
|
107
|
+
let n = 0
|
|
108
|
+
const manager = new AgentManager(registry, undefined, {
|
|
109
|
+
sessionStore: store,
|
|
110
|
+
threadManager: new ThreadManager({ threadStore, sessionStore: store }),
|
|
111
|
+
workspaceRegistry: new WorkspaceBackendRegistry(),
|
|
112
|
+
capacity: new DefaultCapacityValidator(store),
|
|
113
|
+
summaryMaterializer: new SessionSummaryMaterializer({
|
|
114
|
+
store,
|
|
115
|
+
generateSummaryId: () => `sum_${++n}` as SummaryId,
|
|
116
|
+
}),
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const context = {
|
|
120
|
+
parentRunId: 'run_parent' as never,
|
|
121
|
+
parentAgentId: 'sup',
|
|
122
|
+
parentAbortController: new AbortController(),
|
|
123
|
+
depth: 0,
|
|
124
|
+
budgetTracker: { total: 100_000, remaining: 100_000 },
|
|
125
|
+
tenantId: TENANT,
|
|
126
|
+
threadId: thread.id,
|
|
127
|
+
sessionId: parentSession.id,
|
|
128
|
+
projectId: project.id,
|
|
129
|
+
parentActor,
|
|
130
|
+
} as AgentTaskContext
|
|
131
|
+
|
|
132
|
+
const spawn = () =>
|
|
133
|
+
manager.sendMessage(
|
|
134
|
+
{
|
|
135
|
+
agentId: 'worker',
|
|
136
|
+
input: { messages: [], workingDirectory: '/tmp' } as never,
|
|
137
|
+
parentSessionId: parentSession.id,
|
|
138
|
+
tenantId: TENANT,
|
|
139
|
+
} as never,
|
|
140
|
+
context,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return { store, project, parentSession, projects: new ProjectManager({ store }), spawn }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
describe('a closed workspace takes no new work', () => {
|
|
147
|
+
it('spawns while the workspace is open', async () => {
|
|
148
|
+
// The control. Without it, every assertion below could be passing
|
|
149
|
+
// because the harness cannot spawn at all.
|
|
150
|
+
const h = await harness()
|
|
151
|
+
|
|
152
|
+
await expect(h.spawn()).resolves.toBeDefined()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('refuses a spawn into an archived workspace, through the real spawn path', async () => {
|
|
156
|
+
const h = await harness()
|
|
157
|
+
await h.projects.archive(h.project.id, TENANT)
|
|
158
|
+
|
|
159
|
+
await expect(h.spawn()).rejects.toBeInstanceOf(ProjectClosedError)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('names the operation it refused', async () => {
|
|
163
|
+
// "Archived" alone does not tell a caller what they were denied.
|
|
164
|
+
const h = await harness()
|
|
165
|
+
await h.projects.archive(h.project.id, TENANT)
|
|
166
|
+
|
|
167
|
+
const error = await h.spawn().then(
|
|
168
|
+
() => null,
|
|
169
|
+
(e: unknown) => e,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
expect((error as ProjectClosedError).details).toEqual({
|
|
173
|
+
projectId: h.project.id,
|
|
174
|
+
op: 'spawn',
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('spawns again after the workspace is reopened', async () => {
|
|
179
|
+
// Closing has to be reversible, or a mistaken archive is permanent.
|
|
180
|
+
const h = await harness()
|
|
181
|
+
await h.projects.archive(h.project.id, TENANT)
|
|
182
|
+
await h.projects.reopen(h.project.id, TENANT)
|
|
183
|
+
|
|
184
|
+
await expect(h.spawn()).resolves.toBeDefined()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('refuses to close a workspace with a live session, and says which', async () => {
|
|
188
|
+
// Archival does not cascade and does not kill: a live session is a
|
|
189
|
+
// running agent whose owner is still watching.
|
|
190
|
+
const h = await harness()
|
|
191
|
+
await h.store.updateSession({ ...h.parentSession, status: 'active' }, TENANT)
|
|
192
|
+
|
|
193
|
+
const error = await h.projects.archive(h.project.id, TENANT).then(
|
|
194
|
+
() => null,
|
|
195
|
+
(e: unknown) => e,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
expect(error).toBeInstanceOf(ProjectNotEmptyError)
|
|
199
|
+
const details = (error as ProjectNotEmptyError).details
|
|
200
|
+
expect(details.totalBlockingSessions).toBe(1)
|
|
201
|
+
expect(details.blockingSessions).toEqual([{ sessionId: h.parentSession.id, status: 'active' }])
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('closes a workspace whose sessions have settled', async () => {
|
|
205
|
+
const h = await harness()
|
|
206
|
+
|
|
207
|
+
const archived = await h.projects.archive(h.project.id, TENANT)
|
|
208
|
+
|
|
209
|
+
expect(archived.status).toBe('archived')
|
|
210
|
+
expect((await h.store.getProject(h.project.id, TENANT))?.status).toBe('archived')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('re-archiving is a no-op that does not burn a version', async () => {
|
|
214
|
+
// An idempotent call that bumped the counter would invalidate a version
|
|
215
|
+
// a concurrent caller is holding, turning a retry into a conflict.
|
|
216
|
+
const h = await harness()
|
|
217
|
+
const first = await h.projects.archive(h.project.id, TENANT)
|
|
218
|
+
|
|
219
|
+
const second = await h.projects.archive(h.project.id, TENANT)
|
|
220
|
+
|
|
221
|
+
expect(second.ownerVersion).toBe(first.ownerVersion)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('loses the second of two writes that read the same version', async () => {
|
|
225
|
+
// The store compares against what it holds, not against the caller's
|
|
226
|
+
// copy of it — the mistake the session CAS was written with first.
|
|
227
|
+
const h = await harness()
|
|
228
|
+
const read = await h.store.getProject(h.project.id, TENANT)
|
|
229
|
+
if (!read) throw new Error('project vanished')
|
|
230
|
+
|
|
231
|
+
await h.store.setProjectStatus?.(h.project.id, 'archived', TENANT, read.ownerVersion)
|
|
232
|
+
|
|
233
|
+
await expect(
|
|
234
|
+
h.store.setProjectStatus?.(h.project.id, 'open', TENANT, read.ownerVersion),
|
|
235
|
+
).rejects.toBeInstanceOf(StaleProjectError)
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
describe('a workspace stored before it had a status', () => {
|
|
240
|
+
const dirs: string[] = []
|
|
241
|
+
afterEach(async () => {
|
|
242
|
+
await Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true })))
|
|
243
|
+
dirs.length = 0
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('reads as open at version 0, and can still be closed', async () => {
|
|
247
|
+
// Every project.json on disk today lacks both fields. Reading them as
|
|
248
|
+
// `undefined` would be worse than a wrong default: the compare-and-set
|
|
249
|
+
// would never match, so an existing workspace could never be closed.
|
|
250
|
+
const root = await mkdtemp(join(tmpdir(), 'namzu-oldproj-'))
|
|
251
|
+
dirs.push(root)
|
|
252
|
+
const store = new DiskSessionStore({ rootDir: root })
|
|
253
|
+
const project = await store.createProject({ tenantId: TENANT, name: 'w' }, TENANT)
|
|
254
|
+
|
|
255
|
+
const file = join(root, 'projects', project.id, 'project.json')
|
|
256
|
+
const raw = JSON.parse(await readFile(file, 'utf-8'))
|
|
257
|
+
raw.status = undefined
|
|
258
|
+
raw.ownerVersion = undefined
|
|
259
|
+
await writeFile(file, JSON.stringify(raw), 'utf-8')
|
|
260
|
+
|
|
261
|
+
const reloaded = await store.getProject(project.id, TENANT)
|
|
262
|
+
expect(reloaded?.status).toBe('open')
|
|
263
|
+
expect(reloaded?.ownerVersion).toBe(0)
|
|
264
|
+
|
|
265
|
+
const archived = await new ProjectManager({ store }).archive(project.id, TENANT)
|
|
266
|
+
expect(archived.status).toBe('archived')
|
|
267
|
+
})
|
|
268
|
+
})
|