@things-factory/ai-assistant 10.1.29 → 10.1.31
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/dist-server/service/assistant-mode/index.d.ts +1 -1
- package/dist-server/service/assistant-mode/index.js +1 -1
- package/dist-server/service/assistant-mode/index.js.map +1 -1
- package/dist-server/service/testing/conversation-script.d.ts +7 -0
- package/dist-server/service/testing/conversation-script.js +1 -0
- package/dist-server/service/testing/conversation-script.js.map +1 -1
- package/dist-server/service/tool-access.js +20 -7
- package/dist-server/service/tool-access.js.map +1 -1
- package/dist-server/tool-registry-boot-report.js +8 -9
- package/dist-server/tool-registry-boot-report.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/server/service/assistant-mode/index.ts +1 -1
- package/server/service/testing/conversation-scenario.test.ts +2 -1
- package/server/service/testing/conversation-script.test.ts +4 -2
- package/server/service/testing/conversation-script.ts +8 -0
- package/server/service/tool-access.test.ts +67 -19
- package/server/service/tool-access.ts +24 -15
- package/server/tool-registry-boot-report.ts +8 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/ai-assistant",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.31",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
"@operato/graphql": "^10.0.0",
|
|
30
30
|
"@operato/i18n": "^10.0.0",
|
|
31
31
|
"@operato/styles": "^10.0.0",
|
|
32
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
33
|
-
"@things-factory/auth-base": "^10.1.
|
|
32
|
+
"@things-factory/ai-client-base": "^10.1.31",
|
|
33
|
+
"@things-factory/auth-base": "^10.1.31",
|
|
34
34
|
"@things-factory/env": "^10.1.20",
|
|
35
|
-
"@things-factory/shell": "^10.1.
|
|
35
|
+
"@things-factory/shell": "^10.1.31",
|
|
36
36
|
"dompurify": "^3.0.0",
|
|
37
37
|
"graphql-scalars": "^1.22.0",
|
|
38
38
|
"graphql-tag": "^2.12.6",
|
|
@@ -44,5 +44,5 @@
|
|
|
44
44
|
"copyfiles": "^2.4.1",
|
|
45
45
|
"rimraf": "^5.0.0"
|
|
46
46
|
},
|
|
47
|
-
"gitHead": "
|
|
47
|
+
"gitHead": "5213e6f9913b40ea2618a2111d9884b792dbaa01"
|
|
48
48
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Modes — what each conversation is, declared by the app server that owns the screen.
|
|
3
3
|
*
|
|
4
|
-
* Design: ADR-0061 decision 7 (`operato-
|
|
4
|
+
* Design: ADR-0061 decision 7 (`operato-application/adr/ADR-0061.md`) and the AI standard's A2.
|
|
5
5
|
*/
|
|
6
6
|
export {
|
|
7
7
|
registerAssistantMode,
|
|
@@ -22,10 +22,11 @@ beforeEach(() => {
|
|
|
22
22
|
name: 'twin-ops',
|
|
23
23
|
guidance: 'call summarizeStatus before describing the line',
|
|
24
24
|
specs: [
|
|
25
|
-
{ kind: 'read', name: 'summarizeStatus', description: 'read the line', schema: {}, builder: async () => ({ line: 3, stopped: true }) },
|
|
25
|
+
{ kind: 'read', name: 'summarizeStatus', doors: ['Query.twinReplay'], description: 'read the line', schema: {}, builder: async () => ({ line: 3, stopped: true }) },
|
|
26
26
|
{
|
|
27
27
|
kind: 'read',
|
|
28
28
|
name: 'proposeAction',
|
|
29
|
+
doors: ['Query.twinReplay'],
|
|
29
30
|
description: 'propose stopping the line',
|
|
30
31
|
schema: {},
|
|
31
32
|
builder: async () => ({ proposed: true, command: 'stop', instanceId: 'LINE-3', label: '3 라인 정지' })
|
|
@@ -16,8 +16,8 @@ beforeEach(() => {
|
|
|
16
16
|
name: 'twin-ops',
|
|
17
17
|
guidance: 'call summarizeStatus before describing the line',
|
|
18
18
|
specs: [
|
|
19
|
-
{ kind: 'read', name: 'summarizeStatus', description: 'read the line', schema: {}, builder: async () => ({ stopped: true }) },
|
|
20
|
-
{ kind: 'write', name: 'stopLine', description: 'stop the line', schema: {}, builder: async () => ({ ok: true }) }
|
|
19
|
+
{ kind: 'read', name: 'summarizeStatus', doors: ['Query.twinReplay'], description: 'read the line', schema: {}, builder: async () => ({ stopped: true }) },
|
|
20
|
+
{ kind: 'write', name: 'stopLine', doors: ['Mutation.twinCommand'], description: 'stop the line', schema: {}, builder: async () => ({ ok: true }) }
|
|
21
21
|
]
|
|
22
22
|
} as any)
|
|
23
23
|
})
|
|
@@ -101,6 +101,8 @@ it('★ a proposal belongs to the turn that asked for it', async () => {
|
|
|
101
101
|
{
|
|
102
102
|
kind: 'read',
|
|
103
103
|
name: 'proposeSceneEdit',
|
|
104
|
+
/* 저작 도구는 열린 씬만 본다 — 저장소에 닿지 않으므로 문이 없다. */
|
|
105
|
+
doors: [],
|
|
104
106
|
description: 'propose placing a component',
|
|
105
107
|
schema: {},
|
|
106
108
|
builder: async () => ({ proposed: true, kind: 'scene-edit', label: 'place EQ-1', ops: [] })
|
|
@@ -131,6 +131,13 @@ export interface ConversationOptions {
|
|
|
131
131
|
systemPrompt?: string
|
|
132
132
|
/** Which registered categories this conversation may use. Omitted allows none. */
|
|
133
133
|
toolCategories?: string[]
|
|
134
|
+
/**
|
|
135
|
+
* Which doors this script's speaker may pass (ADR-0071 decision 2). A tool is offered when the
|
|
136
|
+
* caller passes every door it names, so a script that says nothing here is written as someone who
|
|
137
|
+
* passes them all — the permission axis belongs to `tool-access`, not to a conversation script.
|
|
138
|
+
* Name the doors to write the other case: a person the tool is not offered to.
|
|
139
|
+
*/
|
|
140
|
+
toolAccess?: (door: string) => Promise<boolean>
|
|
134
141
|
/** Request state the tools receive (domain, user). */
|
|
135
142
|
state?: any
|
|
136
143
|
/** What the surface is looking at — reaches tools as `ctx.host`, never the model. */
|
|
@@ -263,6 +270,7 @@ export function conversation(options: ConversationOptions = {}) {
|
|
|
263
270
|
messages: buildLlmHistory(rows, {}) as any,
|
|
264
271
|
systemPrompt: options.systemPrompt ?? 'you are a test assistant',
|
|
265
272
|
toolCategories: options.toolCategories,
|
|
273
|
+
toolAccess: options.toolAccess ?? (async () => true),
|
|
266
274
|
hostContext: options.hostContext,
|
|
267
275
|
state: options.state ?? { domain: { id: 'dom-1' }, user: { id: 'u-1' } },
|
|
268
276
|
requireToolOnFirstTurn: options.requireToolOnFirstTurn
|
|
@@ -1,48 +1,96 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* What this guards: a registered tool is asked the same question its GraphQL door asks.
|
|
3
3
|
*
|
|
4
|
-
* The tool
|
|
4
|
+
* The tool names the door; AUTH's field table says what that door declares; `checkPermission` runs
|
|
5
5
|
* with the request's user, domain, address safety and prohibited list. If this passed fewer of
|
|
6
6
|
* those — say, dropped `unsafeIP` — the tool would be a softer way into the same door.
|
|
7
|
+
*
|
|
8
|
+
* `checkPermission` itself is fixed by auth-base's own tests (`permission-gate.test.ts`), including
|
|
9
|
+
* the two cases below: a domain owner passes a role-less door from a safe address, and from an
|
|
10
|
+
* unsafe one ownership counts for nothing and the role decides. What this file fixes is that the
|
|
11
|
+
* tool gate hands that function everything it needs to answer either way.
|
|
7
12
|
*/
|
|
8
13
|
const mockCheckPermission = jest.fn()
|
|
9
|
-
|
|
14
|
+
const mockPrivilegeOfField = jest.fn()
|
|
15
|
+
jest.mock('@things-factory/auth-base', () => ({
|
|
16
|
+
checkPermission: (...a: any[]) => mockCheckPermission(...a),
|
|
17
|
+
privilegeOfField: (...a: any[]) => mockPrivilegeOfField(...a)
|
|
18
|
+
}))
|
|
10
19
|
|
|
11
20
|
import { toolAccessFor } from './tool-access'
|
|
12
21
|
|
|
13
22
|
const state = {
|
|
14
23
|
domain: { id: 'dom-1' },
|
|
15
24
|
user: { id: 'u-1' },
|
|
16
|
-
unsafeIP:
|
|
25
|
+
unsafeIP: false,
|
|
17
26
|
prohibitedPrivileges: [{ category: 'board-import', privilege: 'mutation' }]
|
|
18
27
|
}
|
|
19
28
|
|
|
20
|
-
|
|
29
|
+
/** What plant's five doors declare — `@privilege(category, privilege, domainOwnerGranted: true)`. */
|
|
30
|
+
const OWNER_GRANTED = { category: 'job-order', privilege: 'query', owner: true }
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
mockCheckPermission.mockReset()
|
|
34
|
+
mockPrivilegeOfField.mockReset()
|
|
35
|
+
mockPrivilegeOfField.mockReturnValue({ known: true, gate: OWNER_GRANTED })
|
|
36
|
+
})
|
|
21
37
|
|
|
22
|
-
it('★ asks checkPermission with the
|
|
38
|
+
it('★ asks checkPermission with the door’s own declaration and everything the door would pass', async () => {
|
|
23
39
|
mockCheckPermission.mockResolvedValue(true)
|
|
24
40
|
|
|
25
|
-
const allowed = await toolAccessFor({ state })(
|
|
41
|
+
const allowed = await toolAccessFor({ state })('Query.jobOrders')
|
|
26
42
|
|
|
27
43
|
expect(allowed).toBe(true)
|
|
28
|
-
expect(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
expect(mockPrivilegeOfField).toHaveBeenCalledWith('Query', 'jobOrders')
|
|
45
|
+
expect(mockCheckPermission).toHaveBeenCalledWith(OWNER_GRANTED, state.user, state.domain, false, state.prohibitedPrivileges)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* Plant's five doors are all `domainOwnerGranted: true`, so moving the gate onto the doors opens
|
|
50
|
+
* those five tools to a domain owner holding no privilege row. That is the right answer — the owner
|
|
51
|
+
* already sees the same numbers on the screen — and it is right only because the address travels
|
|
52
|
+
* with it. From an unsafe address ownership counts for nothing and the role decides.
|
|
53
|
+
*/
|
|
54
|
+
it('★ the owner-granted door gets the address too, so the answer can differ by where the caller sits', async () => {
|
|
55
|
+
mockCheckPermission.mockResolvedValue(true)
|
|
56
|
+
await toolAccessFor({ state })('Query.jobOrders')
|
|
57
|
+
expect(mockCheckPermission).toHaveBeenLastCalledWith(OWNER_GRANTED, state.user, state.domain, false, state.prohibitedPrivileges)
|
|
58
|
+
|
|
59
|
+
/* The same owner, the same door, an unsafe address: auth is asked again with that fact. */
|
|
60
|
+
mockCheckPermission.mockResolvedValue(false)
|
|
61
|
+
const fromUnsafe = await toolAccessFor({ state: { ...state, unsafeIP: true } })('Query.jobOrders')
|
|
62
|
+
|
|
63
|
+
expect(fromUnsafe).toBe(false)
|
|
64
|
+
expect(mockCheckPermission).toHaveBeenLastCalledWith(OWNER_GRANTED, state.user, state.domain, true, state.prohibitedPrivileges)
|
|
35
65
|
})
|
|
36
66
|
|
|
37
67
|
it('a refusal is a refusal', async () => {
|
|
38
68
|
mockCheckPermission.mockResolvedValue(false)
|
|
39
|
-
expect(await toolAccessFor({ state })(
|
|
69
|
+
expect(await toolAccessFor({ state })('Query.jobOrders')).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('★ a door with no declaration of its own opens as far as that door opens — auth is not asked', async () => {
|
|
73
|
+
mockPrivilegeOfField.mockReturnValue({ known: true, gate: null })
|
|
74
|
+
|
|
75
|
+
expect(await toolAccessFor({ state })('Query.twinReplay')).toBe(true)
|
|
76
|
+
expect(mockCheckPermission).not.toHaveBeenCalled()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('★ a door the schema does not have is closed, not open — a typo hides a tool, it does not free it', async () => {
|
|
80
|
+
mockPrivilegeOfField.mockReturnValue({ known: false })
|
|
81
|
+
|
|
82
|
+
expect(await toolAccessFor({ state })('Query.twinRepaly')).toBe(false)
|
|
83
|
+
expect(mockCheckPermission).not.toHaveBeenCalled()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('a door name that is not a root field passes nobody', async () => {
|
|
87
|
+
expect(await toolAccessFor({ state })('jobOrders')).toBe(false)
|
|
88
|
+
expect(mockPrivilegeOfField).not.toHaveBeenCalled()
|
|
40
89
|
})
|
|
41
90
|
|
|
42
|
-
it('★ a request with no user or no domain
|
|
43
|
-
|
|
44
|
-
expect(await toolAccessFor({ state: {
|
|
45
|
-
expect(await toolAccessFor(
|
|
46
|
-
expect(await toolAccessFor(undefined)(privilege)).toBe(false)
|
|
91
|
+
it('★ a request with no user or no domain passes no door, and auth is not asked', async () => {
|
|
92
|
+
expect(await toolAccessFor({ state: { domain: state.domain } })('Query.jobOrders')).toBe(false)
|
|
93
|
+
expect(await toolAccessFor({ state: { user: state.user } })('Query.jobOrders')).toBe(false)
|
|
94
|
+
expect(await toolAccessFor(undefined)('Query.jobOrders')).toBe(false)
|
|
47
95
|
expect(mockCheckPermission).not.toHaveBeenCalled()
|
|
48
96
|
})
|
|
@@ -2,26 +2,35 @@
|
|
|
2
2
|
* Who may use a registered tool — the request's answer to `ToolAccessCheck`.
|
|
3
3
|
*
|
|
4
4
|
* `ai-client-base` decides *which* tools a conversation may use but does not know auth, so the
|
|
5
|
-
* conversation doors hand it this. A tool
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* Asking anything looser here would make the tool a second,
|
|
5
|
+
* conversation doors hand it this. A tool names the GraphQL doors it reaches (ADR-0071 decision 2);
|
|
6
|
+
* this resolves each one against AUTH's field table (`privilegeOfField`, ADR-0072 decision 5) and
|
|
7
|
+
* asks `checkPermission` exactly what `@privilege` on that field asks — the same user, domain,
|
|
8
|
+
* address safety and prohibited list. Asking anything looser here would make the tool a second,
|
|
9
|
+
* softer way into that door.
|
|
10
|
+
*
|
|
11
|
+
* **The declaration is the door's, and it is read, not copied.** Until 2026-09-18 a tool carried its
|
|
12
|
+
* own `{ category, privilege }` and this asked that copy. A copy drifts from the door it mirrors and
|
|
13
|
+
* the looser of the two wins; the table removed the need for one.
|
|
14
|
+
*
|
|
15
|
+
* The three answers of the table are read exactly as the reader's gate reads them (`door-access.ts`)
|
|
16
|
+
* — an unknown field closes, a field with no declaration opens as far as that field opens.
|
|
9
17
|
*/
|
|
10
|
-
import { checkPermission } from '@things-factory/auth-base'
|
|
11
|
-
import type { ToolAccessCheck
|
|
18
|
+
import { checkPermission, privilegeOfField } from '@things-factory/auth-base'
|
|
19
|
+
import type { ToolAccessCheck } from '@things-factory/ai-client-base'
|
|
20
|
+
|
|
21
|
+
import { doorCheck } from './chat-message/door-access.js'
|
|
12
22
|
|
|
13
23
|
export function toolAccessFor(context: { state?: any } | undefined): ToolAccessCheck {
|
|
14
24
|
const { domain, user, unsafeIP, prohibitedPrivileges } = context?.state ?? {}
|
|
15
25
|
|
|
16
|
-
|
|
17
|
-
|
|
26
|
+
const passes = doorCheck({
|
|
27
|
+
lookup: privilegeOfField,
|
|
28
|
+
permits: gate => checkPermission(gate as any, user, domain, unsafeIP, prohibitedPrivileges)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
return async (door: string) => {
|
|
32
|
+
/* No caller, no answer — a request without a user or domain passes no door. */
|
|
18
33
|
if (!user || !domain) return false
|
|
19
|
-
return
|
|
20
|
-
{ category: privilege.category, privilege: privilege.privilege, owner: privilege.owner, super: privilege.super },
|
|
21
|
-
user,
|
|
22
|
-
domain,
|
|
23
|
-
unsafeIP,
|
|
24
|
-
prohibitedPrivileges
|
|
25
|
-
))
|
|
34
|
+
return await passes(door)
|
|
26
35
|
}
|
|
27
36
|
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* At boot, say what registered but is not yet what the contracts require — tools
|
|
3
|
-
* (
|
|
2
|
+
* At boot, say what registered but is not yet what the contracts require — tools that named no doors
|
|
3
|
+
* (ADR-0071 decision 2) and modes offering `/` sentences their tools cannot answer (ADR-0058
|
|
4
|
+
* decision 3).
|
|
4
5
|
*
|
|
5
6
|
* One window, because it is one question: what state is this deployment's AI in (standard A7-a).
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* empty across the apps, registration becomes a refusal and this report goes away.
|
|
8
|
+
* A tool that names no doors still registers; refusing at registration would stop a whole app from
|
|
9
|
+
* booting over one omission. It is the **gate** that refuses it, tool by tool — such a tool is
|
|
10
|
+
* offered to no conversation (`resolveToolScope`). So this list is not a soft warning about
|
|
11
|
+
* something that still works: it names the tools that are not working, and why.
|
|
12
12
|
*/
|
|
13
13
|
import { logger } from '@things-factory/env'
|
|
14
14
|
import { getToolRegistryWarnings, type ToolRegistryWarning } from '@things-factory/ai-client-base'
|
|
@@ -22,8 +22,7 @@ export function formatToolRegistryWarnings(warnings: ReadonlyArray<ToolRegistryW
|
|
|
22
22
|
for (const warning of warnings) {
|
|
23
23
|
const tools = byCode.get(warning.code) ?? []
|
|
24
24
|
/* 문 이름이 있으면 함께 적는다 — 「어느 문이 아직 선언이 없나」가 다음에 할 일이다. */
|
|
25
|
-
|
|
26
|
-
tools.push(`${warning.params.category}/${warning.params.tool}(${warning.params.kind})${doors}`)
|
|
25
|
+
tools.push(`${warning.params.category}/${warning.params.tool}(${warning.params.kind})`)
|
|
27
26
|
byCode.set(warning.code, tools)
|
|
28
27
|
}
|
|
29
28
|
return [...byCode].map(([code, tools]) => `[ai-assistant] ${code} ×${tools.length}: ${tools.join(', ')}`)
|