@namzu/sdk 13.0.0 → 13.1.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 +86 -0
- 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 +33 -0
- package/dist/manager/agent/lifecycle.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 +3 -1
- package/dist/store/session/disk.d.ts.map +1 -1
- package/dist/store/session/disk.js +56 -2
- package/dist/store/session/disk.js.map +1 -1
- package/dist/store/session/memory.d.ts +3 -1
- package/dist/store/session/memory.d.ts.map +1 -1
- package/dist/store/session/memory.js +39 -2
- 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/session/store.d.ts +52 -0
- package/dist/types/session/store.d.ts.map +1 -1
- package/package.json +1 -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 +36 -0
- package/src/store/session/__tests__/a-workspace-can-be-configured.test.ts +210 -0
- package/src/store/session/disk.ts +63 -2
- package/src/store/session/memory.ts +46 -2
- 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/session/store.ts +60 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { mkdtemp, rm } 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 { TenantIsolationError } from '../../../session/errors.js'
|
|
7
|
+
import type { TenantId } from '../../../types/ids/index.js'
|
|
8
|
+
import type { SessionStore } from '../../../types/session/store.js'
|
|
9
|
+
import { DiskSessionStore } from '../disk.js'
|
|
10
|
+
import { InMemorySessionStore } from '../memory.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every project in existence ran at depth 4 and width 8.
|
|
14
|
+
*
|
|
15
|
+
* The config was hardcoded identically in both stores, `CreateProjectParams`
|
|
16
|
+
* was `{tenantId, name}`, and there was no `updateProject`. So a tenant with
|
|
17
|
+
* several workspaces could not give them different limits — which is most of
|
|
18
|
+
* what having several workspaces is for.
|
|
19
|
+
*
|
|
20
|
+
* Only the two fields something READS are settable. `ProjectConfig` declares
|
|
21
|
+
* eight; five enforcement sites read two of them. Exposing the other six would
|
|
22
|
+
* make dead fields easier to set, and a host that configures a retention policy
|
|
23
|
+
* and gets no error believes retention is on. `maxInterventionDepth` looks like
|
|
24
|
+
* an exception and is not: its three apparent readers are all comments.
|
|
25
|
+
*
|
|
26
|
+
* Both stores are driven by the same cases, because a reference implementation
|
|
27
|
+
* that disagrees with the durable one is worse than having only one.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const TENANT = 'tnt_cfg' as TenantId
|
|
31
|
+
const OTHER = 'tnt_other' as TenantId
|
|
32
|
+
|
|
33
|
+
const dirs: string[] = []
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
await Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true })))
|
|
36
|
+
dirs.length = 0
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
async function diskStore(): Promise<SessionStore> {
|
|
40
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'namzu-cfg-'))
|
|
41
|
+
dirs.push(rootDir)
|
|
42
|
+
return new DiskSessionStore({ rootDir })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const IMPLEMENTATIONS: ReadonlyArray<readonly [string, () => Promise<SessionStore>]> = [
|
|
46
|
+
['in memory', async () => new InMemorySessionStore()],
|
|
47
|
+
['on disk', diskStore],
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
describe.each(IMPLEMENTATIONS)('a workspace carries its own limits (%s)', (_name, build) => {
|
|
51
|
+
it('takes the limits it was created with', async () => {
|
|
52
|
+
const store = await build()
|
|
53
|
+
|
|
54
|
+
const project = await store.createProject(
|
|
55
|
+
{ tenantId: TENANT, name: 'w', config: { maxDelegationDepth: 2, maxDelegationWidth: 3 } },
|
|
56
|
+
TENANT,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
expect(project.config.maxDelegationDepth).toBe(2)
|
|
60
|
+
expect(project.config.maxDelegationWidth).toBe(3)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('keeps the defaults for anything not given', async () => {
|
|
64
|
+
const store = await build()
|
|
65
|
+
|
|
66
|
+
const project = await store.createProject(
|
|
67
|
+
{ tenantId: TENANT, name: 'w', config: { maxDelegationDepth: 2 } },
|
|
68
|
+
TENANT,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
expect(project.config.maxDelegationDepth).toBe(2)
|
|
72
|
+
expect(project.config.maxDelegationWidth).toBe(8)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('lets two workspaces of one tenant differ, which is the point', async () => {
|
|
76
|
+
const store = await build()
|
|
77
|
+
|
|
78
|
+
const narrow = await store.createProject(
|
|
79
|
+
{ tenantId: TENANT, name: 'narrow', config: { maxDelegationWidth: 1 } },
|
|
80
|
+
TENANT,
|
|
81
|
+
)
|
|
82
|
+
const wide = await store.createProject(
|
|
83
|
+
{ tenantId: TENANT, name: 'wide', config: { maxDelegationWidth: 16 } },
|
|
84
|
+
TENANT,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
expect(narrow.config.maxDelegationWidth).toBe(1)
|
|
88
|
+
expect(wide.config.maxDelegationWidth).toBe(16)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('changes a limit after the fact, and the change is readable back', async () => {
|
|
92
|
+
const store = await build()
|
|
93
|
+
const project = await store.createProject({ tenantId: TENANT, name: 'w' }, TENANT)
|
|
94
|
+
|
|
95
|
+
await store.updateProject?.(project.id, { maxDelegationWidth: 12 }, TENANT)
|
|
96
|
+
|
|
97
|
+
expect((await store.getProject(project.id, TENANT))?.config.maxDelegationWidth).toBe(12)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('leaves a limit it was not asked to change', async () => {
|
|
101
|
+
// Per field, not whole-value: raising the width says nothing about the
|
|
102
|
+
// depth, and resetting it would be an answer to a question nobody asked.
|
|
103
|
+
const store = await build()
|
|
104
|
+
const project = await store.createProject(
|
|
105
|
+
{ tenantId: TENANT, name: 'w', config: { maxDelegationDepth: 2 } },
|
|
106
|
+
TENANT,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
await store.updateProject?.(project.id, { maxDelegationWidth: 12 }, TENANT)
|
|
110
|
+
|
|
111
|
+
const reloaded = await store.getProject(project.id, TENANT)
|
|
112
|
+
expect(reloaded?.config.maxDelegationDepth).toBe(2)
|
|
113
|
+
expect(reloaded?.config.maxDelegationWidth).toBe(12)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('treats an explicitly undefined limit as "leave it", not "clear it"', async () => {
|
|
117
|
+
// The case the per-field guard exists for, and the one a plain spread
|
|
118
|
+
// gets wrong: `{...config}` with an explicit `undefined` key writes the
|
|
119
|
+
// undefined through and erases a limit the caller never mentioned. A
|
|
120
|
+
// caller building an update object programmatically produces exactly
|
|
121
|
+
// this shape.
|
|
122
|
+
const store = await build()
|
|
123
|
+
const project = await store.createProject(
|
|
124
|
+
{ tenantId: TENANT, name: 'w', config: { maxDelegationDepth: 3 } },
|
|
125
|
+
TENANT,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
await store.updateProject?.(
|
|
129
|
+
project.id,
|
|
130
|
+
{ maxDelegationDepth: undefined, maxDelegationWidth: 12 },
|
|
131
|
+
TENANT,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const reloaded = await store.getProject(project.id, TENANT)
|
|
135
|
+
expect(reloaded?.config.maxDelegationDepth).toBe(3)
|
|
136
|
+
expect(reloaded?.config.maxDelegationWidth).toBe(12)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('lists what this tenant owns, oldest first', async () => {
|
|
140
|
+
// Eight, spaced past the millisecond `createdAt` is measured in, so this
|
|
141
|
+
// is an assertion about age rather than about the tie-break below. With
|
|
142
|
+
// the sort dropped the disk store returns them in directory order, which
|
|
143
|
+
// is id-ascending — it matches creation order once in 8!.
|
|
144
|
+
const store = await build()
|
|
145
|
+
const created = []
|
|
146
|
+
for (const name of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) {
|
|
147
|
+
created.push(await store.createProject({ tenantId: TENANT, name }, TENANT))
|
|
148
|
+
await new Promise((resolve) => setTimeout(resolve, 2))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const listed = await store.listProjects?.(TENANT)
|
|
152
|
+
|
|
153
|
+
expect(listed?.map((p) => p.id)).toEqual(created.map((p) => p.id))
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('stays a total order when several are created in the same millisecond', async () => {
|
|
157
|
+
// CI found this and a slower machine could not: on a fast filesystem
|
|
158
|
+
// projects routinely share a `createdAt`, and "oldest first" alone left
|
|
159
|
+
// the rest to `readdir`. A caller paginating a listing that reorders
|
|
160
|
+
// under it sees the same project twice and never sees another.
|
|
161
|
+
const store = await build()
|
|
162
|
+
for (const name of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) {
|
|
163
|
+
await store.createProject({ tenantId: TENANT, name }, TENANT)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const listed = (await store.listProjects?.(TENANT)) ?? []
|
|
167
|
+
|
|
168
|
+
expect(listed).toHaveLength(8)
|
|
169
|
+
for (let i = 1; i < listed.length; i++) {
|
|
170
|
+
const before = listed[i - 1]
|
|
171
|
+
const after = listed[i]
|
|
172
|
+
if (before === undefined || after === undefined) throw new Error('short listing')
|
|
173
|
+
const olderFirst = before.createdAt.getTime() < after.createdAt.getTime()
|
|
174
|
+
const tieByName =
|
|
175
|
+
before.createdAt.getTime() === after.createdAt.getTime() && before.id < after.id
|
|
176
|
+
expect({ pair: [before.name, after.name], ordered: olderFirst || tieByName }).toEqual({
|
|
177
|
+
pair: [before.name, after.name],
|
|
178
|
+
ordered: true,
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('omits another tenant from the listing rather than refusing', async () => {
|
|
184
|
+
// A listing is a question about what you own. Refusing would confirm
|
|
185
|
+
// that somebody else's project is there, which is the leak the tenant
|
|
186
|
+
// boundary exists to prevent.
|
|
187
|
+
const store = await build()
|
|
188
|
+
await store.createProject({ tenantId: TENANT, name: 'mine' }, TENANT)
|
|
189
|
+
await store.createProject({ tenantId: OTHER, name: 'theirs' }, OTHER)
|
|
190
|
+
|
|
191
|
+
expect((await store.listProjects?.(TENANT))?.map((p) => p.name)).toEqual(['mine'])
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('refuses to reconfigure another tenant project', async () => {
|
|
195
|
+
// Reading a listing is a question; writing to somebody else's workspace
|
|
196
|
+
// is not, so this one throws.
|
|
197
|
+
const store = await build()
|
|
198
|
+
const theirs = await store.createProject({ tenantId: OTHER, name: 'theirs' }, OTHER)
|
|
199
|
+
|
|
200
|
+
await expect(
|
|
201
|
+
store.updateProject?.(theirs.id, { maxDelegationWidth: 99 }, TENANT),
|
|
202
|
+
).rejects.toBeInstanceOf(TenantIsolationError)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('returns null for a project that does not exist', async () => {
|
|
206
|
+
const store = await build()
|
|
207
|
+
|
|
208
|
+
expect(await store.updateProject?.('prj_missing' as never, {}, TENANT)).toBeNull()
|
|
209
|
+
})
|
|
210
|
+
})
|
|
@@ -35,6 +35,7 @@ import type {
|
|
|
35
35
|
CreateProjectParams,
|
|
36
36
|
CreateSessionParams,
|
|
37
37
|
CreateSubSessionParams,
|
|
38
|
+
ProjectConfigInput,
|
|
38
39
|
SessionStore,
|
|
39
40
|
SessionView,
|
|
40
41
|
} from '../../types/session/store.js'
|
|
@@ -193,8 +194,8 @@ export class DiskSessionStore implements SessionStore {
|
|
|
193
194
|
tenantId,
|
|
194
195
|
name: params.name,
|
|
195
196
|
config: {
|
|
196
|
-
maxDelegationDepth: 4,
|
|
197
|
-
maxDelegationWidth: 8,
|
|
197
|
+
maxDelegationDepth: params.config?.maxDelegationDepth ?? 4,
|
|
198
|
+
maxDelegationWidth: params.config?.maxDelegationWidth ?? 8,
|
|
198
199
|
maxInterventionDepth: 10,
|
|
199
200
|
},
|
|
200
201
|
createdAt: now,
|
|
@@ -215,6 +216,66 @@ export class DiskSessionStore implements SessionStore {
|
|
|
215
216
|
return deserializeProject(raw)
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
async updateProject(
|
|
220
|
+
projectId: ProjectId,
|
|
221
|
+
config: ProjectConfigInput,
|
|
222
|
+
tenantId: TenantId,
|
|
223
|
+
): Promise<Project | null> {
|
|
224
|
+
const existing = await this.getProject(projectId, tenantId)
|
|
225
|
+
if (!existing) return null
|
|
226
|
+
// Per field, like the in-memory store: an omitted limit is left alone
|
|
227
|
+
// rather than reset.
|
|
228
|
+
const project: Project = {
|
|
229
|
+
...existing,
|
|
230
|
+
config: {
|
|
231
|
+
...existing.config,
|
|
232
|
+
...(config.maxDelegationDepth !== undefined
|
|
233
|
+
? { maxDelegationDepth: config.maxDelegationDepth }
|
|
234
|
+
: {}),
|
|
235
|
+
...(config.maxDelegationWidth !== undefined
|
|
236
|
+
? { maxDelegationWidth: config.maxDelegationWidth }
|
|
237
|
+
: {}),
|
|
238
|
+
},
|
|
239
|
+
updatedAt: new Date(),
|
|
240
|
+
}
|
|
241
|
+
await atomicWriteJson(
|
|
242
|
+
join(this.projectDir(projectId), 'project.json'),
|
|
243
|
+
serializeProject(project),
|
|
244
|
+
)
|
|
245
|
+
return project
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async listProjects(tenantId: TenantId): Promise<readonly Project[]> {
|
|
249
|
+
// Read from the directory rather than the lazily-built index: the index
|
|
250
|
+
// only knows about projects this instance has already touched, so a
|
|
251
|
+
// listing built from it would omit everything written by a previous
|
|
252
|
+
// process — which for a store whose whole point is durability is the
|
|
253
|
+
// wrong answer.
|
|
254
|
+
const projectsRoot = join(this.rootDir, 'projects')
|
|
255
|
+
let entries: string[]
|
|
256
|
+
try {
|
|
257
|
+
entries = await readdir(projectsRoot)
|
|
258
|
+
} catch {
|
|
259
|
+
return []
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const found: Project[] = []
|
|
263
|
+
for (const entry of entries) {
|
|
264
|
+
const raw = await readJson<PersistedProject>(join(projectsRoot, entry, 'project.json'))
|
|
265
|
+
if (!raw) continue
|
|
266
|
+
// Another tenant's project is absent, not an error — a listing is a
|
|
267
|
+
// question about what you own, and refusing would leak that
|
|
268
|
+
// somebody else's project is there.
|
|
269
|
+
if (raw.tenantId !== tenantId) continue
|
|
270
|
+
found.push(deserializeProject(raw))
|
|
271
|
+
}
|
|
272
|
+
// Tie-broken by id — see the in-memory store. On a fast filesystem two
|
|
273
|
+
// projects share a millisecond routinely, and without this the order
|
|
274
|
+
// came from readdir.
|
|
275
|
+
found.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id))
|
|
276
|
+
return found
|
|
277
|
+
}
|
|
278
|
+
|
|
218
279
|
// Session CRUD ------------------------------------------------------------
|
|
219
280
|
|
|
220
281
|
async createSession(params: CreateSessionParams, tenantId: TenantId): Promise<Session> {
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
CreateProjectParams,
|
|
21
21
|
CreateSessionParams,
|
|
22
22
|
CreateSubSessionParams,
|
|
23
|
+
ProjectConfigInput,
|
|
23
24
|
SessionStore,
|
|
24
25
|
SessionView,
|
|
25
26
|
} from '../../types/session/store.js'
|
|
@@ -88,8 +89,8 @@ export class InMemorySessionStore implements SessionStore {
|
|
|
88
89
|
tenantId,
|
|
89
90
|
name: params.name,
|
|
90
91
|
config: {
|
|
91
|
-
maxDelegationDepth: 4,
|
|
92
|
-
maxDelegationWidth: 8,
|
|
92
|
+
maxDelegationDepth: params.config?.maxDelegationDepth ?? 4,
|
|
93
|
+
maxDelegationWidth: params.config?.maxDelegationWidth ?? 8,
|
|
93
94
|
maxInterventionDepth: 10,
|
|
94
95
|
},
|
|
95
96
|
createdAt: now,
|
|
@@ -106,6 +107,49 @@ export class InMemorySessionStore implements SessionStore {
|
|
|
106
107
|
return record.project
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
async updateProject(
|
|
111
|
+
projectId: ProjectId,
|
|
112
|
+
config: ProjectConfigInput,
|
|
113
|
+
tenantId: TenantId,
|
|
114
|
+
): Promise<Project | null> {
|
|
115
|
+
const record = this.projects.get(projectId)
|
|
116
|
+
if (!record) return null
|
|
117
|
+
this.assertTenant(record.tenantId, tenantId, `project(${projectId})`)
|
|
118
|
+
// Per field: an omitted limit is left alone rather than reset, because a
|
|
119
|
+
// caller raising the width is saying nothing about the depth.
|
|
120
|
+
const project: Project = {
|
|
121
|
+
...record.project,
|
|
122
|
+
config: {
|
|
123
|
+
...record.project.config,
|
|
124
|
+
...(config.maxDelegationDepth !== undefined
|
|
125
|
+
? { maxDelegationDepth: config.maxDelegationDepth }
|
|
126
|
+
: {}),
|
|
127
|
+
...(config.maxDelegationWidth !== undefined
|
|
128
|
+
? { maxDelegationWidth: config.maxDelegationWidth }
|
|
129
|
+
: {}),
|
|
130
|
+
},
|
|
131
|
+
updatedAt: new Date(),
|
|
132
|
+
}
|
|
133
|
+
this.projects.set(projectId, { tenantId, project })
|
|
134
|
+
return project
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async listProjects(tenantId: TenantId): Promise<readonly Project[]> {
|
|
138
|
+
const matches: Project[] = []
|
|
139
|
+
for (const record of this.projects.values()) {
|
|
140
|
+
if (record.tenantId !== tenantId) continue
|
|
141
|
+
matches.push(record.project)
|
|
142
|
+
}
|
|
143
|
+
// Tie-broken by id, because two projects created in the same
|
|
144
|
+
// millisecond otherwise fall back to insertion or directory order and
|
|
145
|
+
// "oldest first" stops being a total order. A caller paginating a
|
|
146
|
+
// listing that reorders under it sees items move between pages.
|
|
147
|
+
matches.sort(
|
|
148
|
+
(a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id),
|
|
149
|
+
)
|
|
150
|
+
return matches
|
|
151
|
+
}
|
|
152
|
+
|
|
109
153
|
// Session CRUD ------------------------------------------------------------
|
|
110
154
|
|
|
111
155
|
async createSession(params: CreateSessionParams, tenantId: TenantId): Promise<Session> {
|
|
@@ -146,6 +146,13 @@ export function buildAgentTool(opts: AgentToolOptions): ToolDefinition {
|
|
|
146
146
|
// surface, and the one it exports as the canonical shape —
|
|
147
147
|
// did not.
|
|
148
148
|
...(context.parentSpan ? { parentSpan: context.parentSpan } : {}),
|
|
149
|
+
// The parent's environment, which is the whole point of setting
|
|
150
|
+
// one: a delegate that cannot see it runs against different
|
|
151
|
+
// services than the run that launched it, silently.
|
|
152
|
+
// `ToolContext.env` is the parent's own resolved map, per run.
|
|
153
|
+
...(Object.keys(context.env ?? {}).length > 0
|
|
154
|
+
? { configOverrides: { env: context.env } }
|
|
155
|
+
: {}),
|
|
149
156
|
})
|
|
150
157
|
|
|
151
158
|
onTaskLaunched?.(handle.taskId, {
|
|
@@ -484,6 +484,12 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
|
|
|
484
484
|
// Hang the child run off THIS tool's span, so the delegation
|
|
485
485
|
// shows up inside the turn that asked for it.
|
|
486
486
|
...(_context.parentSpan ? { parentSpan: _context.parentSpan } : {}),
|
|
487
|
+
// Same as the `Agent` tool: a delegate inherits the environment
|
|
488
|
+
// its parent was given, or it runs against different services
|
|
489
|
+
// than the run that asked for the work.
|
|
490
|
+
...(Object.keys(_context.env ?? {}).length > 0
|
|
491
|
+
? { configOverrides: { env: _context.env } }
|
|
492
|
+
: {}),
|
|
487
493
|
})
|
|
488
494
|
|
|
489
495
|
// Whose task this is. The inbox ignores completions for anything it
|
package/src/types/agent/base.ts
CHANGED
|
@@ -22,6 +22,29 @@ export interface BaseAgentConfig {
|
|
|
22
22
|
maxResponseTokens?: number
|
|
23
23
|
costLimitUsd?: number
|
|
24
24
|
permissionMode?: PermissionMode
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extra environment variables for this agent's tools and sandboxed
|
|
28
|
+
* commands, merged over whatever ambient environment the execution path
|
|
29
|
+
* supplies. Inherited by every delegated descendant.
|
|
30
|
+
*
|
|
31
|
+
* **Configuration, not credentials** — and that is a property of the
|
|
32
|
+
* CHANNEL rather than a judgement about any particular value. This map is
|
|
33
|
+
* copied into every child, is readable by any tool that can run a command,
|
|
34
|
+
* and enters a model's context and the run transcript the moment something
|
|
35
|
+
* echoes it. Nothing here is scoped, redacted, or revocable.
|
|
36
|
+
*
|
|
37
|
+
* A value that authenticates to a host belongs on the brokered credential
|
|
38
|
+
* path instead, where the process holds a placeholder and the real value is
|
|
39
|
+
* attached per-host on egress — so it is never in the environment, never in
|
|
40
|
+
* a transcript, and never inherited by a child that had no business with it.
|
|
41
|
+
*
|
|
42
|
+
* Inheritance was broken until it was not: a child built through a
|
|
43
|
+
* `configBuilder` never received this at all, because the builder is
|
|
44
|
+
* written by whoever registered the agent and cannot forward a field it was
|
|
45
|
+
* never told about. It is stamped after the builder returns now, for the
|
|
46
|
+
* same reason `parentSpan` and `resumeHandler` are.
|
|
47
|
+
*/
|
|
25
48
|
env?: Record<string, string>
|
|
26
49
|
|
|
27
50
|
/**
|
|
@@ -65,9 +65,42 @@ export interface CreateSubSessionParams {
|
|
|
65
65
|
* is out of scope for this phase (session-hierarchy.md §11 defers the project
|
|
66
66
|
* store to a later phase).
|
|
67
67
|
*/
|
|
68
|
+
/**
|
|
69
|
+
* The part of a Project's configuration a caller may actually set.
|
|
70
|
+
*
|
|
71
|
+
* **Exactly the fields something reads.** `ProjectConfig` declares eight; five
|
|
72
|
+
* enforcement sites read two of them, and the other six have zero readers in
|
|
73
|
+
* production — `maxInterventionDepth` included, whose three apparent hits are
|
|
74
|
+
* all comments claiming a wiring that does not exist. Exposing those here
|
|
75
|
+
* would make a dead field *easier to set*, which is worse than leaving it
|
|
76
|
+
* unreachable: a host would configure a retention policy, get no error, and
|
|
77
|
+
* believe retention was on.
|
|
78
|
+
*
|
|
79
|
+
* The rule is the repo's own: name the code that reads a declaration before
|
|
80
|
+
* shipping it. When a field gains a reader it gains a line here in the same
|
|
81
|
+
* change, and not before.
|
|
82
|
+
*/
|
|
83
|
+
export interface ProjectConfigInput {
|
|
84
|
+
/** Read by the spawn path and both handoff paths. Default 4. */
|
|
85
|
+
maxDelegationDepth?: number
|
|
86
|
+
/** Read by the spawn path and broadcast handoff. Default 8. */
|
|
87
|
+
maxDelegationWidth?: number
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
export interface CreateProjectParams {
|
|
69
91
|
tenantId: TenantId
|
|
70
92
|
name: string
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Per-workspace limits. Omitted fields keep the defaults.
|
|
96
|
+
*
|
|
97
|
+
* Until this existed every project in existence ran at depth 4 / width 8,
|
|
98
|
+
* because the config was hardcoded identically in both stores and there was
|
|
99
|
+
* no way to write one afterwards. A tenant with several workspaces could
|
|
100
|
+
* not give them different limits, which is most of what having several
|
|
101
|
+
* workspaces is for.
|
|
102
|
+
*/
|
|
103
|
+
config?: ProjectConfigInput
|
|
71
104
|
}
|
|
72
105
|
|
|
73
106
|
/**
|
|
@@ -99,6 +132,33 @@ export interface SessionStore {
|
|
|
99
132
|
|
|
100
133
|
getProject(projectId: ProjectId, tenantId: TenantId): Promise<Project | null>
|
|
101
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Change a Project's limits after it exists. OPTIONAL.
|
|
137
|
+
*
|
|
138
|
+
* Optional because widening a store interface is invisible to callers and
|
|
139
|
+
* fatal to implementors: a host with its own `SessionStore` should not stop
|
|
140
|
+
* compiling because the SDK grew a method. Callers check for it; the two
|
|
141
|
+
* stores here implement it.
|
|
142
|
+
*
|
|
143
|
+
* Only the fields in {@link ProjectConfigInput} can move, and an omitted
|
|
144
|
+
* field is left alone rather than reset — a caller raising the width is not
|
|
145
|
+
* saying anything about the depth. Returns the updated Project, or `null`
|
|
146
|
+
* if it does not exist.
|
|
147
|
+
*/
|
|
148
|
+
updateProject?(
|
|
149
|
+
projectId: ProjectId,
|
|
150
|
+
config: ProjectConfigInput,
|
|
151
|
+
tenantId: TenantId,
|
|
152
|
+
): Promise<Project | null>
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Every Project this tenant owns, oldest first. OPTIONAL, same reasoning.
|
|
156
|
+
*
|
|
157
|
+
* The tenant is the isolation boundary, so this is scoped to it and to
|
|
158
|
+
* nothing else — there is no level above Project to filter by.
|
|
159
|
+
*/
|
|
160
|
+
listProjects?(tenantId: TenantId): Promise<readonly Project[]>
|
|
161
|
+
|
|
102
162
|
// Session CRUD ------------------------------------------------------------
|
|
103
163
|
|
|
104
164
|
createSession(params: CreateSessionParams, tenantId: TenantId): Promise<Session>
|