@company-semantics/contracts 0.86.0 → 0.88.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/package.json +2 -2
- package/src/api/http/routes/ai-chat.ts +37 -0
- package/src/execution/__tests__/lifecycle.test.ts +262 -0
- package/src/execution/errors.ts +18 -0
- package/src/execution/expiry.ts +34 -0
- package/src/execution/index.ts +34 -0
- package/src/execution/lifecycle.ts +39 -0
- package/src/execution/status.ts +47 -0
- package/src/index.ts +3 -1
- package/src/interfaces/mcp/tools/help.ts +76 -0
- package/src/mcp/__tests__/capability-graph.test.ts +421 -0
- package/src/mcp/capability-graph.ts +113 -0
- package/src/mcp/failure-context.ts +48 -0
- package/src/mcp/index.ts +3 -0
- package/src/message-parts/confirmation.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@company-semantics/contracts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.88.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"guard:test": "vitest run scripts/ci/__tests__",
|
|
78
78
|
"release": "npx tsx scripts/release.ts",
|
|
79
79
|
"prepublishOnly": "echo 'ERROR: Publishing is CI-only via tag push. Use pnpm release instead.' && exit 1",
|
|
80
|
-
"test": "vitest run
|
|
80
|
+
"test": "vitest run"
|
|
81
81
|
},
|
|
82
82
|
"packageManager": "pnpm@10.25.0",
|
|
83
83
|
"engines": {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Chat Route — Tool Discovery API Contract
|
|
3
|
+
*
|
|
4
|
+
* Shared helper for building tool discovery responses with optional
|
|
5
|
+
* capability graph inclusion via ?include=graph query parameter.
|
|
6
|
+
*
|
|
7
|
+
* Backend implementation: company-semantics-backend/src/api/http/routes/capabilities.ts
|
|
8
|
+
*
|
|
9
|
+
* Consumer usage:
|
|
10
|
+
* import { buildCapabilityGraph } from '@company-semantics/contracts'
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { buildCapabilityGraph } from '../../../mcp/capability-graph'
|
|
14
|
+
import type {
|
|
15
|
+
MCPToolDescriptor,
|
|
16
|
+
ToolDiscoveryResponse,
|
|
17
|
+
CapabilityGraph,
|
|
18
|
+
} from '../../../mcp/index'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build a ToolDiscoveryResponse, optionally including the capability graph.
|
|
22
|
+
*
|
|
23
|
+
* When include=graph query parameter is present, the response includes
|
|
24
|
+
* the graph derived from tool resource flow metadata.
|
|
25
|
+
*
|
|
26
|
+
* @param tools - Tool descriptors to include in response
|
|
27
|
+
* @param includeGraph - Whether to include the capability graph (from ?include=graph)
|
|
28
|
+
*/
|
|
29
|
+
export function buildToolDiscoveryResponse(
|
|
30
|
+
tools: MCPToolDescriptor[],
|
|
31
|
+
includeGraph: boolean,
|
|
32
|
+
): ToolDiscoveryResponse {
|
|
33
|
+
return {
|
|
34
|
+
tools,
|
|
35
|
+
...(includeGraph && { graph: buildCapabilityGraph(tools) }),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { ExecutionState } from '../status.js'
|
|
3
|
+
import {
|
|
4
|
+
VALID_TRANSITIONS,
|
|
5
|
+
TERMINAL_STATES,
|
|
6
|
+
EFFECTIVE_TERMINAL_STATES,
|
|
7
|
+
assertValidTransition,
|
|
8
|
+
} from '../status.js'
|
|
9
|
+
import { LIFECYCLE_DECISIONS, applyIntent } from '../lifecycle.js'
|
|
10
|
+
import type { ExecutionErrorCode } from '../errors.js'
|
|
11
|
+
import { ExecutionError } from '../errors.js'
|
|
12
|
+
import { parseExpiresAt, isConfirmationExpired } from '../expiry.js'
|
|
13
|
+
|
|
14
|
+
const ALL_STATES: ExecutionState[] = [
|
|
15
|
+
'pending_confirmation',
|
|
16
|
+
'blocked_pending_approval',
|
|
17
|
+
'ready',
|
|
18
|
+
'executing',
|
|
19
|
+
'completed',
|
|
20
|
+
'completed_with_rollbacks',
|
|
21
|
+
'failed_retryable',
|
|
22
|
+
'failed_terminal',
|
|
23
|
+
'failed_with_partial_execution',
|
|
24
|
+
'cancelled',
|
|
25
|
+
'expired',
|
|
26
|
+
'undone',
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// 1. EXHAUSTIVE TRANSITION MATRIX
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
describe('exhaustive transition matrix', () => {
|
|
33
|
+
for (const from of ALL_STATES) {
|
|
34
|
+
for (const to of ALL_STATES) {
|
|
35
|
+
const allowed = VALID_TRANSITIONS[from].includes(to)
|
|
36
|
+
it(`${from} -> ${to} should be ${allowed ? 'valid' : 'invalid'}`, () => {
|
|
37
|
+
if (allowed) {
|
|
38
|
+
expect(() => assertValidTransition(from, to)).not.toThrow()
|
|
39
|
+
} else {
|
|
40
|
+
expect(() => assertValidTransition(from, to)).toThrow(
|
|
41
|
+
`Invalid execution state transition: ${from} -> ${to}`,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// 2. TERMINAL_STATES CONSISTENCY
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
describe('TERMINAL_STATES consistency', () => {
|
|
53
|
+
it('contains exactly the states with empty transition arrays', () => {
|
|
54
|
+
const expectedTerminal = ALL_STATES.filter(
|
|
55
|
+
(s) => VALID_TRANSITIONS[s].length === 0,
|
|
56
|
+
)
|
|
57
|
+
expect(expectedTerminal.length).toBeGreaterThan(0)
|
|
58
|
+
for (const state of expectedTerminal) {
|
|
59
|
+
expect(TERMINAL_STATES.has(state)).toBe(true)
|
|
60
|
+
}
|
|
61
|
+
expect(TERMINAL_STATES.size).toBe(expectedTerminal.length)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('does not contain states with non-empty transition arrays', () => {
|
|
65
|
+
const nonTerminal = ALL_STATES.filter(
|
|
66
|
+
(s) => VALID_TRANSITIONS[s].length > 0,
|
|
67
|
+
)
|
|
68
|
+
for (const state of nonTerminal) {
|
|
69
|
+
expect(TERMINAL_STATES.has(state)).toBe(false)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// 3. EFFECTIVE_TERMINAL_STATES
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
describe('EFFECTIVE_TERMINAL_STATES', () => {
|
|
78
|
+
it('contains all TERMINAL_STATES', () => {
|
|
79
|
+
for (const state of TERMINAL_STATES) {
|
|
80
|
+
expect(EFFECTIVE_TERMINAL_STATES.has(state)).toBe(true)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('contains completed', () => {
|
|
85
|
+
expect(EFFECTIVE_TERMINAL_STATES.has('completed')).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('has size equal to TERMINAL_STATES + 1 (completed)', () => {
|
|
89
|
+
expect(EFFECTIVE_TERMINAL_STATES.size).toBe(TERMINAL_STATES.size + 1)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('does not contain non-terminal, non-completed states', () => {
|
|
93
|
+
const nonEffectiveTerminal = ALL_STATES.filter(
|
|
94
|
+
(s) => !TERMINAL_STATES.has(s) && s !== 'completed',
|
|
95
|
+
)
|
|
96
|
+
for (const state of nonEffectiveTerminal) {
|
|
97
|
+
expect(EFFECTIVE_TERMINAL_STATES.has(state)).toBe(false)
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// 4. DECISION TABLE KEY CONSISTENCY
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
describe('decision table key consistency', () => {
|
|
106
|
+
it('LIFECYCLE_DECISIONS.confirm keys match VALID_TRANSITIONS keys', () => {
|
|
107
|
+
const transitionKeys = Object.keys(VALID_TRANSITIONS).sort()
|
|
108
|
+
const decisionKeys = Object.keys(LIFECYCLE_DECISIONS.confirm).sort()
|
|
109
|
+
expect(decisionKeys).toEqual(transitionKeys)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// 5. APPROVE-TRANSITION DRIFT
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
describe('approve-transition drift', () => {
|
|
117
|
+
it('every state with approve decision can transition to ready', () => {
|
|
118
|
+
for (const state of ALL_STATES) {
|
|
119
|
+
if (LIFECYCLE_DECISIONS.confirm[state] === 'approve') {
|
|
120
|
+
expect(
|
|
121
|
+
VALID_TRANSITIONS[state].includes('ready'),
|
|
122
|
+
).toBe(true)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// 6. APPLY_INTENT UNIT TESTS
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
describe('applyIntent', () => {
|
|
132
|
+
it('pending_confirmation + confirm = approve', () => {
|
|
133
|
+
expect(applyIntent('pending_confirmation', 'confirm')).toBe('approve')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('expired + confirm = expired', () => {
|
|
137
|
+
expect(applyIntent('expired', 'confirm')).toBe('expired')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('cancelled + confirm = conflict', () => {
|
|
141
|
+
expect(applyIntent('cancelled', 'confirm')).toBe('conflict')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('ready + confirm = already_confirmed', () => {
|
|
145
|
+
expect(applyIntent('ready', 'confirm')).toBe('already_confirmed')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('blocked_pending_approval + confirm = awaiting_approval', () => {
|
|
149
|
+
expect(applyIntent('blocked_pending_approval', 'confirm')).toBe(
|
|
150
|
+
'awaiting_approval',
|
|
151
|
+
)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('failed_terminal + confirm = conflict', () => {
|
|
155
|
+
expect(applyIntent('failed_terminal', 'confirm')).toBe('conflict')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('undone + confirm = conflict', () => {
|
|
159
|
+
expect(applyIntent('undone', 'confirm')).toBe('conflict')
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// 7. EXECUTION_ERROR TESTS
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
describe('ExecutionError', () => {
|
|
167
|
+
it('has correct name', () => {
|
|
168
|
+
const err = new ExecutionError('execution_expired', 'test', 410)
|
|
169
|
+
expect(err.name).toBe('ExecutionError')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('has correct code', () => {
|
|
173
|
+
const err = new ExecutionError('execution_invalid_transition', 'bad transition', 409)
|
|
174
|
+
expect(err.code).toBe('execution_invalid_transition')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('has correct message', () => {
|
|
178
|
+
const err = new ExecutionError('execution_not_found', 'not found', 404)
|
|
179
|
+
expect(err.message).toBe('not found')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('has correct httpStatus', () => {
|
|
183
|
+
const err = new ExecutionError('execution_forbidden', 'forbidden', 403)
|
|
184
|
+
expect(err.httpStatus).toBe(403)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('httpStatus is optional', () => {
|
|
188
|
+
const err = new ExecutionError('execution_expired', 'expired')
|
|
189
|
+
expect(err.httpStatus).toBeUndefined()
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('instanceof Error works', () => {
|
|
193
|
+
const err = new ExecutionError('execution_expired', 'expired', 410)
|
|
194
|
+
expect(err).toBeInstanceOf(Error)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('instanceof ExecutionError works', () => {
|
|
198
|
+
const err = new ExecutionError('execution_expired', 'expired', 410)
|
|
199
|
+
expect(err).toBeInstanceOf(ExecutionError)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('supports all error codes', () => {
|
|
203
|
+
const codes: ExecutionErrorCode[] = [
|
|
204
|
+
'execution_expired',
|
|
205
|
+
'execution_already_confirmed',
|
|
206
|
+
'execution_awaiting_approval',
|
|
207
|
+
'execution_not_found',
|
|
208
|
+
'execution_forbidden',
|
|
209
|
+
'execution_invalid_transition',
|
|
210
|
+
]
|
|
211
|
+
for (const code of codes) {
|
|
212
|
+
const err = new ExecutionError(code, `msg-${code}`)
|
|
213
|
+
expect(err.code).toBe(code)
|
|
214
|
+
expect(err.name).toBe('ExecutionError')
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// 8. EXPIRY HELPER TESTS
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
describe('parseExpiresAt', () => {
|
|
223
|
+
it('returns undefined for undefined input', () => {
|
|
224
|
+
expect(parseExpiresAt(undefined)).toBeUndefined()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('returns undefined for invalid date string', () => {
|
|
228
|
+
expect(parseExpiresAt('invalid')).toBeUndefined()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('returns correct epoch ms for valid ISO string', () => {
|
|
232
|
+
const result = parseExpiresAt('2026-01-01T00:00:00Z')
|
|
233
|
+
expect(result).toBe(new Date('2026-01-01T00:00:00Z').getTime())
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('returns undefined for empty string', () => {
|
|
237
|
+
expect(parseExpiresAt('')).toBeUndefined()
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
describe('isConfirmationExpired', () => {
|
|
242
|
+
it('returns false for undefined expiresAtMs', () => {
|
|
243
|
+
expect(isConfirmationExpired(undefined)).toBe(false)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('returns true for past timestamp', () => {
|
|
247
|
+
const pastMs = Date.now() - 60_000
|
|
248
|
+
expect(isConfirmationExpired(pastMs)).toBe(true)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('returns false for future timestamp', () => {
|
|
252
|
+
const futureMs = Date.now() + 60_000
|
|
253
|
+
expect(isConfirmationExpired(futureMs)).toBe(false)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('works with custom now parameter', () => {
|
|
257
|
+
const expiresAtMs = 1000
|
|
258
|
+
expect(isConfirmationExpired(expiresAtMs, 999)).toBe(false)
|
|
259
|
+
expect(isConfirmationExpired(expiresAtMs, 1000)).toBe(true)
|
|
260
|
+
expect(isConfirmationExpired(expiresAtMs, 1001)).toBe(true)
|
|
261
|
+
})
|
|
262
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type ExecutionErrorCode =
|
|
2
|
+
| 'execution_expired'
|
|
3
|
+
| 'execution_already_confirmed'
|
|
4
|
+
| 'execution_awaiting_approval'
|
|
5
|
+
| 'execution_not_found'
|
|
6
|
+
| 'execution_forbidden'
|
|
7
|
+
| 'execution_invalid_transition';
|
|
8
|
+
|
|
9
|
+
export class ExecutionError extends Error {
|
|
10
|
+
constructor(
|
|
11
|
+
public readonly code: ExecutionErrorCode,
|
|
12
|
+
message: string,
|
|
13
|
+
public readonly httpStatus?: number,
|
|
14
|
+
) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'ExecutionError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized expiry check helpers for execution confirmations.
|
|
3
|
+
*
|
|
4
|
+
* Expiry logic lives here instead of inline in the confirm endpoint,
|
|
5
|
+
* the background job, and the client timer. Centralizing prevents drift.
|
|
6
|
+
*
|
|
7
|
+
* Uses pre-parsed epoch ms (number) instead of re-parsing ISO strings
|
|
8
|
+
* to avoid repeated Date construction.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parses an ISO 8601 string to epoch ms.
|
|
13
|
+
* Returns undefined if the input is undefined or an invalid date (NaN guard).
|
|
14
|
+
* Called once at creation time; the result is compared cheaply thereafter.
|
|
15
|
+
*/
|
|
16
|
+
export function parseExpiresAt(
|
|
17
|
+
expiresAt: string | undefined,
|
|
18
|
+
): number | undefined {
|
|
19
|
+
if (expiresAt === undefined) return undefined
|
|
20
|
+
const ms = new Date(expiresAt).getTime()
|
|
21
|
+
return Number.isFinite(ms) ? ms : undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Compares pre-parsed epoch ms to determine if a confirmation has expired.
|
|
26
|
+
* Defaults `now` to Date.now(). Returns false if expiresAtMs is undefined.
|
|
27
|
+
*/
|
|
28
|
+
export function isConfirmationExpired(
|
|
29
|
+
expiresAtMs: number | undefined,
|
|
30
|
+
now?: number,
|
|
31
|
+
): boolean {
|
|
32
|
+
if (expiresAtMs === undefined) return false
|
|
33
|
+
return (now ?? Date.now()) >= expiresAtMs
|
|
34
|
+
}
|
package/src/execution/index.ts
CHANGED
|
@@ -72,6 +72,40 @@ export type { ExecutionKind } from './kinds'
|
|
|
72
72
|
|
|
73
73
|
export type { ExecutionState } from './status'
|
|
74
74
|
|
|
75
|
+
export {
|
|
76
|
+
VALID_TRANSITIONS,
|
|
77
|
+
TERMINAL_STATES,
|
|
78
|
+
EFFECTIVE_TERMINAL_STATES,
|
|
79
|
+
assertValidTransition,
|
|
80
|
+
} from './status'
|
|
81
|
+
|
|
82
|
+
// =============================================================================
|
|
83
|
+
// Lifecycle Decision Engine
|
|
84
|
+
// =============================================================================
|
|
85
|
+
|
|
86
|
+
export type {
|
|
87
|
+
ExecutionIntent as LifecycleIntent,
|
|
88
|
+
LifecycleDecision,
|
|
89
|
+
} from './lifecycle'
|
|
90
|
+
|
|
91
|
+
export {
|
|
92
|
+
LIFECYCLE_DECISIONS,
|
|
93
|
+
applyIntent,
|
|
94
|
+
} from './lifecycle'
|
|
95
|
+
|
|
96
|
+
// =============================================================================
|
|
97
|
+
// Expiry Helpers
|
|
98
|
+
// =============================================================================
|
|
99
|
+
|
|
100
|
+
export { parseExpiresAt, isConfirmationExpired } from './expiry'
|
|
101
|
+
|
|
102
|
+
// =============================================================================
|
|
103
|
+
// Error Types
|
|
104
|
+
// =============================================================================
|
|
105
|
+
|
|
106
|
+
export type { ExecutionErrorCode } from './errors'
|
|
107
|
+
export { ExecutionError } from './errors'
|
|
108
|
+
|
|
75
109
|
// =============================================================================
|
|
76
110
|
// Definition Types
|
|
77
111
|
// =============================================================================
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ExecutionState } from './status';
|
|
2
|
+
|
|
3
|
+
export type ExecutionIntent = 'confirm';
|
|
4
|
+
|
|
5
|
+
export type LifecycleDecision =
|
|
6
|
+
| 'approve'
|
|
7
|
+
| 'already_confirmed'
|
|
8
|
+
| 'awaiting_approval'
|
|
9
|
+
| 'expired'
|
|
10
|
+
| 'conflict';
|
|
11
|
+
|
|
12
|
+
type DecisionTable = Record<
|
|
13
|
+
ExecutionIntent,
|
|
14
|
+
Record<ExecutionState, LifecycleDecision>
|
|
15
|
+
>;
|
|
16
|
+
|
|
17
|
+
export const LIFECYCLE_DECISIONS: DecisionTable = {
|
|
18
|
+
confirm: {
|
|
19
|
+
pending_confirmation: 'approve',
|
|
20
|
+
expired: 'expired',
|
|
21
|
+
cancelled: 'conflict',
|
|
22
|
+
blocked_pending_approval: 'awaiting_approval',
|
|
23
|
+
ready: 'already_confirmed',
|
|
24
|
+
executing: 'already_confirmed',
|
|
25
|
+
completed: 'already_confirmed',
|
|
26
|
+
completed_with_rollbacks: 'already_confirmed',
|
|
27
|
+
failed_retryable: 'conflict',
|
|
28
|
+
failed_terminal: 'conflict',
|
|
29
|
+
failed_with_partial_execution: 'conflict',
|
|
30
|
+
undone: 'conflict',
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function applyIntent(
|
|
35
|
+
state: ExecutionState,
|
|
36
|
+
intent: ExecutionIntent,
|
|
37
|
+
): LifecycleDecision {
|
|
38
|
+
return LIFECYCLE_DECISIONS[intent][state];
|
|
39
|
+
}
|
package/src/execution/status.ts
CHANGED
|
@@ -41,6 +41,17 @@
|
|
|
41
41
|
* bigint), not timestamps. UNIQUE(execution_id, event_sequence) enforced
|
|
42
42
|
* at database level. event_sequence for ordering, created_at for duration —
|
|
43
43
|
* distinct concerns.
|
|
44
|
+
*
|
|
45
|
+
* ## Execution Invariants
|
|
46
|
+
*
|
|
47
|
+
* - **INV-EXEC-1**: Events are append-only. No UPDATE/DELETE on execution_events.
|
|
48
|
+
* - **INV-EXEC-2**: Current state = stateAfter of latest event (by event_sequence).
|
|
49
|
+
* execution_audit.state is a denormalized cache.
|
|
50
|
+
* - **INV-EXEC-3**: All transitions must satisfy VALID_TRANSITIONS[from].includes(to).
|
|
51
|
+
* - **INV-EXEC-4**: Transition validation + event insertion within same
|
|
52
|
+
* advisory-locked transaction.
|
|
53
|
+
* - **INV-EXEC-5**: failed_retryable -> ready loop bounded by executor retry budget.
|
|
54
|
+
* Retry events must include retryAttempt in metadata.
|
|
44
55
|
*/
|
|
45
56
|
export type ExecutionState =
|
|
46
57
|
| 'pending_confirmation'
|
|
@@ -55,3 +66,39 @@ export type ExecutionState =
|
|
|
55
66
|
| 'cancelled'
|
|
56
67
|
| 'expired'
|
|
57
68
|
| 'undone';
|
|
69
|
+
|
|
70
|
+
export const VALID_TRANSITIONS: Record<ExecutionState, readonly ExecutionState[]> = {
|
|
71
|
+
pending_confirmation: ['ready', 'blocked_pending_approval', 'cancelled', 'expired'],
|
|
72
|
+
blocked_pending_approval: ['ready', 'cancelled', 'expired'],
|
|
73
|
+
ready: ['executing'],
|
|
74
|
+
executing: [
|
|
75
|
+
'completed', 'completed_with_rollbacks',
|
|
76
|
+
'failed_retryable', 'failed_terminal',
|
|
77
|
+
'failed_with_partial_execution', 'cancelled',
|
|
78
|
+
],
|
|
79
|
+
completed: ['undone'],
|
|
80
|
+
completed_with_rollbacks: [],
|
|
81
|
+
failed_retryable: ['ready'],
|
|
82
|
+
failed_terminal: [],
|
|
83
|
+
failed_with_partial_execution: [],
|
|
84
|
+
cancelled: [],
|
|
85
|
+
expired: [],
|
|
86
|
+
undone: [],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const TERMINAL_STATES: ReadonlySet<ExecutionState> = new Set(
|
|
90
|
+
(Object.entries(VALID_TRANSITIONS) as [ExecutionState, readonly ExecutionState[]][])
|
|
91
|
+
.filter(([, targets]) => targets.length === 0)
|
|
92
|
+
.map(([state]) => state)
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
export const EFFECTIVE_TERMINAL_STATES: ReadonlySet<ExecutionState> = new Set([
|
|
96
|
+
...TERMINAL_STATES,
|
|
97
|
+
'completed',
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
export function assertValidTransition(from: ExecutionState, to: ExecutionState): void {
|
|
101
|
+
if (!VALID_TRANSITIONS[from].includes(to)) {
|
|
102
|
+
throw new Error(`Invalid execution state transition: ${from} -> ${to}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -282,12 +282,14 @@ export type {
|
|
|
282
282
|
ToolComplexity,
|
|
283
283
|
// Resource flow types (PRD-00265)
|
|
284
284
|
ResourceType,
|
|
285
|
-
// Capability graph
|
|
285
|
+
// Capability graph types (PRD-00265 types, PRD-00268 implementation)
|
|
286
286
|
CapabilityGraph,
|
|
287
287
|
CapabilityGraphEdge,
|
|
288
288
|
ToolWorkflow,
|
|
289
289
|
} from './mcp/index'
|
|
290
290
|
|
|
291
|
+
export { buildCapabilityGraph } from './mcp/index'
|
|
292
|
+
|
|
291
293
|
// Message part types and builder functions
|
|
292
294
|
// @see ADR-2026-01-022 for design rationale
|
|
293
295
|
export type {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Help Tool Enrichment — Workflow Summaries & Domain Groupings
|
|
3
|
+
*
|
|
4
|
+
* Pure formatting functions for enriching cs_help output with
|
|
5
|
+
* workflow summaries and domain groupings derived from the
|
|
6
|
+
* capability graph.
|
|
7
|
+
*
|
|
8
|
+
* These functions accept MCPToolDescriptor[] (the shape returned
|
|
9
|
+
* by getToolHandlers() or getToolDefinitions() in the backend)
|
|
10
|
+
* and produce formatted text sections.
|
|
11
|
+
*
|
|
12
|
+
* @see company-semantics-backend/src/interfaces/mcp/tools/system/help.ts
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { MCPToolDescriptor } from '../../../mcp/index'
|
|
16
|
+
import { buildCapabilityGraph } from '../../../mcp/capability-graph'
|
|
17
|
+
|
|
18
|
+
function capitalize(s: string): string {
|
|
19
|
+
return s.charAt(0).toUpperCase() + s.slice(1)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build workflow summary text from tool descriptors.
|
|
24
|
+
*
|
|
25
|
+
* Derives workflows via buildCapabilityGraph() and formats them
|
|
26
|
+
* for the "Available workflows" section of cs_help output.
|
|
27
|
+
*
|
|
28
|
+
* Usage:
|
|
29
|
+
* const descriptors = getToolDefinitions() // or from getToolHandlers()
|
|
30
|
+
* const section = formatWorkflowSummaries(descriptors)
|
|
31
|
+
*/
|
|
32
|
+
export function formatWorkflowSummaries(
|
|
33
|
+
descriptors: MCPToolDescriptor[],
|
|
34
|
+
): string {
|
|
35
|
+
const graph = buildCapabilityGraph(descriptors)
|
|
36
|
+
if (graph.workflows.length === 0) return ''
|
|
37
|
+
|
|
38
|
+
const lines = graph.workflows.map((w) => {
|
|
39
|
+
const displayName = capitalize(w.name.replace(/_/g, ' '))
|
|
40
|
+
const steps = w.steps.map((s) => s.replace(/^cs_/, '')).join(' → ')
|
|
41
|
+
return ` ${displayName}: ${steps}`
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
return '\nAvailable workflows:\n' + lines.join('\n')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build domain grouping text from tool descriptors.
|
|
49
|
+
*
|
|
50
|
+
* Groups tools by their `domain` field and formats as the
|
|
51
|
+
* "Tool domains" section of cs_help output.
|
|
52
|
+
*
|
|
53
|
+
* Usage:
|
|
54
|
+
* const descriptors = getToolDefinitions() // or from getToolHandlers()
|
|
55
|
+
* const section = formatDomainGroupings(descriptors)
|
|
56
|
+
*/
|
|
57
|
+
export function formatDomainGroupings(
|
|
58
|
+
descriptors: MCPToolDescriptor[],
|
|
59
|
+
): string {
|
|
60
|
+
const domainGroups = new Map<string, string[]>()
|
|
61
|
+
|
|
62
|
+
for (const tool of descriptors) {
|
|
63
|
+
const domain = tool.domain ?? 'unknown'
|
|
64
|
+
if (!domainGroups.has(domain)) domainGroups.set(domain, [])
|
|
65
|
+
domainGroups.get(domain)!.push(
|
|
66
|
+
tool.name.replace(/^cs_/, '').replace(/_/g, ' '),
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const lines = [...domainGroups.entries()].map(
|
|
71
|
+
([domain, tools]) =>
|
|
72
|
+
` ${capitalize(domain)} (${tools.length}): ${tools.join(', ')}`,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return '\nTool domains:\n' + lines.join('\n')
|
|
76
|
+
}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildCapabilityGraph } from '../capability-graph'
|
|
3
|
+
import type { MCPToolDescriptor } from '../index'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Helper to create minimal MCPToolDescriptor for testing.
|
|
7
|
+
* Only id, name, and resource fields matter for graph derivation.
|
|
8
|
+
*/
|
|
9
|
+
function makeTool(
|
|
10
|
+
overrides: Partial<MCPToolDescriptor> & { id: string; name: string },
|
|
11
|
+
): MCPToolDescriptor {
|
|
12
|
+
return {
|
|
13
|
+
category: 'system',
|
|
14
|
+
description: '',
|
|
15
|
+
effectClass: 'pure',
|
|
16
|
+
invocationMode: 'manual',
|
|
17
|
+
visibility: 'user',
|
|
18
|
+
requiresConfirmation: false,
|
|
19
|
+
domain: 'system',
|
|
20
|
+
risk: 'none',
|
|
21
|
+
intent: 'read',
|
|
22
|
+
stability: 'stable',
|
|
23
|
+
complexity: 'trivial',
|
|
24
|
+
schemaVersion: 1,
|
|
25
|
+
...overrides,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('buildCapabilityGraph', () => {
|
|
30
|
+
it('returns empty graph for empty tools array', () => {
|
|
31
|
+
const graph = buildCapabilityGraph([])
|
|
32
|
+
expect(graph.edges).toEqual([])
|
|
33
|
+
expect(graph.workflows).toEqual([])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('produces no edges for tools with no produces/consumes', () => {
|
|
37
|
+
const tools = [
|
|
38
|
+
makeTool({ id: 'a', name: 'a' }),
|
|
39
|
+
makeTool({ id: 'b', name: 'b' }),
|
|
40
|
+
]
|
|
41
|
+
const graph = buildCapabilityGraph(tools)
|
|
42
|
+
expect(graph.edges).toEqual([])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('creates one edge for single producer → consumer with correct domain', () => {
|
|
46
|
+
const tools = [
|
|
47
|
+
makeTool({ id: 'a', name: 'a', produces: ['integration.connection'] }),
|
|
48
|
+
makeTool({ id: 'b', name: 'b', consumes: ['integration.connection'] }),
|
|
49
|
+
]
|
|
50
|
+
const graph = buildCapabilityGraph(tools)
|
|
51
|
+
expect(graph.edges).toHaveLength(1)
|
|
52
|
+
expect(graph.edges[0]).toEqual({
|
|
53
|
+
from: 'a',
|
|
54
|
+
to: 'b',
|
|
55
|
+
resource: 'integration.connection',
|
|
56
|
+
domain: 'integration',
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('creates multiple edges for multiple producers of same resource', () => {
|
|
61
|
+
const tools = [
|
|
62
|
+
makeTool({ id: 'a', name: 'a', produces: ['integration.connection'] }),
|
|
63
|
+
makeTool({ id: 'b', name: 'b', produces: ['integration.connection'] }),
|
|
64
|
+
makeTool({ id: 'c', name: 'c', consumes: ['integration.connection'] }),
|
|
65
|
+
]
|
|
66
|
+
const graph = buildCapabilityGraph(tools)
|
|
67
|
+
expect(graph.edges).toHaveLength(2)
|
|
68
|
+
expect(graph.edges).toContainEqual({
|
|
69
|
+
from: 'a',
|
|
70
|
+
to: 'c',
|
|
71
|
+
resource: 'integration.connection',
|
|
72
|
+
domain: 'integration',
|
|
73
|
+
})
|
|
74
|
+
expect(graph.edges).toContainEqual({
|
|
75
|
+
from: 'b',
|
|
76
|
+
to: 'c',
|
|
77
|
+
resource: 'integration.connection',
|
|
78
|
+
domain: 'integration',
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('derives workflow for linear chain > 2 steps', () => {
|
|
83
|
+
const tools = [
|
|
84
|
+
makeTool({
|
|
85
|
+
id: 'step1',
|
|
86
|
+
name: 'step1',
|
|
87
|
+
produces: ['integration.connection'],
|
|
88
|
+
}),
|
|
89
|
+
makeTool({
|
|
90
|
+
id: 'step2',
|
|
91
|
+
name: 'step2',
|
|
92
|
+
consumes: ['integration.connection'],
|
|
93
|
+
produces: ['slack.channel'],
|
|
94
|
+
}),
|
|
95
|
+
makeTool({
|
|
96
|
+
id: 'step3',
|
|
97
|
+
name: 'step3',
|
|
98
|
+
consumes: ['slack.channel'],
|
|
99
|
+
produces: ['slack.channel_scope'],
|
|
100
|
+
}),
|
|
101
|
+
makeTool({
|
|
102
|
+
id: 'step4',
|
|
103
|
+
name: 'step4',
|
|
104
|
+
consumes: ['slack.channel_scope'],
|
|
105
|
+
}),
|
|
106
|
+
]
|
|
107
|
+
const graph = buildCapabilityGraph(tools)
|
|
108
|
+
expect(graph.workflows).toHaveLength(1)
|
|
109
|
+
expect(graph.workflows[0].steps).toEqual([
|
|
110
|
+
'step1',
|
|
111
|
+
'step2',
|
|
112
|
+
'step3',
|
|
113
|
+
'step4',
|
|
114
|
+
])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('does NOT derive workflow for short chain (2 steps, 1 edge)', () => {
|
|
118
|
+
const tools = [
|
|
119
|
+
makeTool({ id: 'a', name: 'a', produces: ['integration.connection'] }),
|
|
120
|
+
makeTool({ id: 'b', name: 'b', consumes: ['integration.connection'] }),
|
|
121
|
+
]
|
|
122
|
+
const graph = buildCapabilityGraph(tools)
|
|
123
|
+
expect(graph.workflows).toEqual([])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('does not form single workflow from branching nodes', () => {
|
|
127
|
+
const tools = [
|
|
128
|
+
makeTool({
|
|
129
|
+
id: 'root',
|
|
130
|
+
name: 'root',
|
|
131
|
+
produces: ['integration.connection'],
|
|
132
|
+
}),
|
|
133
|
+
makeTool({
|
|
134
|
+
id: 'branch1',
|
|
135
|
+
name: 'branch1',
|
|
136
|
+
consumes: ['integration.connection'],
|
|
137
|
+
produces: ['slack.channel'],
|
|
138
|
+
}),
|
|
139
|
+
makeTool({
|
|
140
|
+
id: 'branch2',
|
|
141
|
+
name: 'branch2',
|
|
142
|
+
consumes: ['integration.connection'],
|
|
143
|
+
produces: ['slack.coverage'],
|
|
144
|
+
}),
|
|
145
|
+
]
|
|
146
|
+
const graph = buildCapabilityGraph(tools)
|
|
147
|
+
// root has 2 outgoing edges → not a linear chain
|
|
148
|
+
expect(graph.workflows).toEqual([])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('full 17-tool integration test', () => {
|
|
152
|
+
const allTools: MCPToolDescriptor[] = [
|
|
153
|
+
// Organization
|
|
154
|
+
makeTool({
|
|
155
|
+
id: 'cs_get_org_status',
|
|
156
|
+
name: 'cs_get_org_status',
|
|
157
|
+
domain: 'organization',
|
|
158
|
+
produces: ['org.status'],
|
|
159
|
+
}),
|
|
160
|
+
makeTool({
|
|
161
|
+
id: 'cs_update_org',
|
|
162
|
+
name: 'cs_update_org',
|
|
163
|
+
domain: 'organization',
|
|
164
|
+
intent: 'mutate',
|
|
165
|
+
}),
|
|
166
|
+
// Identity
|
|
167
|
+
makeTool({
|
|
168
|
+
id: 'cs_update_profile',
|
|
169
|
+
name: 'cs_update_profile',
|
|
170
|
+
domain: 'identity',
|
|
171
|
+
intent: 'mutate',
|
|
172
|
+
}),
|
|
173
|
+
// Integrations
|
|
174
|
+
makeTool({
|
|
175
|
+
id: 'cs_start_slack_auth',
|
|
176
|
+
name: 'cs_start_slack_auth',
|
|
177
|
+
domain: 'integrations',
|
|
178
|
+
integrations: ['slack'],
|
|
179
|
+
produces: ['integration.connection'],
|
|
180
|
+
}),
|
|
181
|
+
makeTool({
|
|
182
|
+
id: 'cs_start_google_auth',
|
|
183
|
+
name: 'cs_start_google_auth',
|
|
184
|
+
domain: 'integrations',
|
|
185
|
+
integrations: ['google'],
|
|
186
|
+
produces: ['integration.connection'],
|
|
187
|
+
}),
|
|
188
|
+
makeTool({
|
|
189
|
+
id: 'cs_start_zoom_auth',
|
|
190
|
+
name: 'cs_start_zoom_auth',
|
|
191
|
+
domain: 'integrations',
|
|
192
|
+
integrations: ['zoom'],
|
|
193
|
+
produces: ['integration.connection'],
|
|
194
|
+
}),
|
|
195
|
+
makeTool({
|
|
196
|
+
id: 'cs_list_connections',
|
|
197
|
+
name: 'cs_list_connections',
|
|
198
|
+
domain: 'integrations',
|
|
199
|
+
produces: ['integration.connection'],
|
|
200
|
+
}),
|
|
201
|
+
makeTool({
|
|
202
|
+
id: 'cs_cleanup_connections',
|
|
203
|
+
name: 'cs_cleanup_connections',
|
|
204
|
+
domain: 'integrations',
|
|
205
|
+
consumes: ['integration.connection'],
|
|
206
|
+
}),
|
|
207
|
+
makeTool({
|
|
208
|
+
id: 'cs_propose_integration_action',
|
|
209
|
+
name: 'cs_propose_integration_action',
|
|
210
|
+
domain: 'integrations',
|
|
211
|
+
consumes: ['integration.connection'],
|
|
212
|
+
}),
|
|
213
|
+
// Discovery
|
|
214
|
+
makeTool({
|
|
215
|
+
id: 'cs_discover_slack',
|
|
216
|
+
name: 'cs_discover_slack',
|
|
217
|
+
domain: 'discovery',
|
|
218
|
+
integrations: ['slack'],
|
|
219
|
+
produces: ['slack.channel'],
|
|
220
|
+
consumes: ['integration.connection'],
|
|
221
|
+
}),
|
|
222
|
+
makeTool({
|
|
223
|
+
id: 'cs_get_slack_coverage',
|
|
224
|
+
name: 'cs_get_slack_coverage',
|
|
225
|
+
domain: 'discovery',
|
|
226
|
+
integrations: ['slack'],
|
|
227
|
+
produces: ['slack.coverage'],
|
|
228
|
+
consumes: ['integration.connection'],
|
|
229
|
+
}),
|
|
230
|
+
makeTool({
|
|
231
|
+
id: 'cs_list_fingerprints',
|
|
232
|
+
name: 'cs_list_fingerprints',
|
|
233
|
+
domain: 'discovery',
|
|
234
|
+
produces: ['knowledge.fingerprint'],
|
|
235
|
+
}),
|
|
236
|
+
// Ingestion
|
|
237
|
+
makeTool({
|
|
238
|
+
id: 'cs_manage_channel_scope',
|
|
239
|
+
name: 'cs_manage_channel_scope',
|
|
240
|
+
domain: 'ingestion',
|
|
241
|
+
integrations: ['slack'],
|
|
242
|
+
produces: ['slack.channel_scope'],
|
|
243
|
+
consumes: ['slack.channel'],
|
|
244
|
+
}),
|
|
245
|
+
makeTool({
|
|
246
|
+
id: 'cs_ingest_slack_channel',
|
|
247
|
+
name: 'cs_ingest_slack_channel',
|
|
248
|
+
domain: 'ingestion',
|
|
249
|
+
integrations: ['slack'],
|
|
250
|
+
produces: ['ingestion.job'],
|
|
251
|
+
consumes: ['slack.channel_scope'],
|
|
252
|
+
}),
|
|
253
|
+
makeTool({
|
|
254
|
+
id: 'cs_get_ingestion_status',
|
|
255
|
+
name: 'cs_get_ingestion_status',
|
|
256
|
+
domain: 'ingestion',
|
|
257
|
+
produces: ['ingestion.job'],
|
|
258
|
+
consumes: ['ingestion.job'],
|
|
259
|
+
}),
|
|
260
|
+
// System
|
|
261
|
+
makeTool({
|
|
262
|
+
id: 'cs_system_status',
|
|
263
|
+
name: 'cs_system_status',
|
|
264
|
+
domain: 'system',
|
|
265
|
+
produces: ['system.status'],
|
|
266
|
+
}),
|
|
267
|
+
makeTool({
|
|
268
|
+
id: 'cs_help',
|
|
269
|
+
name: 'cs_help',
|
|
270
|
+
domain: 'system',
|
|
271
|
+
}),
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
it('includes Slack ingestion chain edges', () => {
|
|
275
|
+
const graph = buildCapabilityGraph(allTools)
|
|
276
|
+
|
|
277
|
+
expect(graph.edges).toContainEqual({
|
|
278
|
+
from: 'cs_start_slack_auth',
|
|
279
|
+
to: 'cs_discover_slack',
|
|
280
|
+
resource: 'integration.connection',
|
|
281
|
+
domain: 'integration',
|
|
282
|
+
})
|
|
283
|
+
expect(graph.edges).toContainEqual({
|
|
284
|
+
from: 'cs_discover_slack',
|
|
285
|
+
to: 'cs_manage_channel_scope',
|
|
286
|
+
resource: 'slack.channel',
|
|
287
|
+
domain: 'slack',
|
|
288
|
+
})
|
|
289
|
+
expect(graph.edges).toContainEqual({
|
|
290
|
+
from: 'cs_manage_channel_scope',
|
|
291
|
+
to: 'cs_ingest_slack_channel',
|
|
292
|
+
resource: 'slack.channel_scope',
|
|
293
|
+
domain: 'slack',
|
|
294
|
+
})
|
|
295
|
+
expect(graph.edges).toContainEqual({
|
|
296
|
+
from: 'cs_ingest_slack_channel',
|
|
297
|
+
to: 'cs_get_ingestion_status',
|
|
298
|
+
resource: 'ingestion.job',
|
|
299
|
+
domain: 'ingestion',
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('produces correct total edge count', () => {
|
|
304
|
+
const graph = buildCapabilityGraph(allTools)
|
|
305
|
+
// integration.connection: 4 producers × 4 consumers = 16 edges
|
|
306
|
+
// slack.channel: 1 edge (discover_slack → manage_channel_scope)
|
|
307
|
+
// slack.channel_scope: 1 edge (manage_channel_scope → ingest_slack_channel)
|
|
308
|
+
// ingestion.job: 2 edges (ingest → status, status → status self-loop)
|
|
309
|
+
expect(graph.edges).toHaveLength(20)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('does not derive workflows from branching graph', () => {
|
|
313
|
+
const graph = buildCapabilityGraph(allTools)
|
|
314
|
+
// With all 17 tools, auth tools have 4 outgoing edges each (branching),
|
|
315
|
+
// and consumers like discover_slack have 4 incoming edges.
|
|
316
|
+
// No linear chain starts are walkable → no workflows.
|
|
317
|
+
expect(graph.workflows).toEqual([])
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
describe('slack_ingestion workflow derivation', () => {
|
|
322
|
+
it('derives slack_ingestion workflow from Slack chain tools', () => {
|
|
323
|
+
// Isolated Slack chain without branching from other auth tools
|
|
324
|
+
const slackChainTools = [
|
|
325
|
+
makeTool({
|
|
326
|
+
id: 'cs_start_slack_auth',
|
|
327
|
+
name: 'cs_start_slack_auth',
|
|
328
|
+
integrations: ['slack'],
|
|
329
|
+
produces: ['integration.connection'],
|
|
330
|
+
}),
|
|
331
|
+
makeTool({
|
|
332
|
+
id: 'cs_discover_slack',
|
|
333
|
+
name: 'cs_discover_slack',
|
|
334
|
+
integrations: ['slack'],
|
|
335
|
+
produces: ['slack.channel'],
|
|
336
|
+
consumes: ['integration.connection'],
|
|
337
|
+
}),
|
|
338
|
+
makeTool({
|
|
339
|
+
id: 'cs_manage_channel_scope',
|
|
340
|
+
name: 'cs_manage_channel_scope',
|
|
341
|
+
integrations: ['slack'],
|
|
342
|
+
produces: ['slack.channel_scope'],
|
|
343
|
+
consumes: ['slack.channel'],
|
|
344
|
+
}),
|
|
345
|
+
makeTool({
|
|
346
|
+
id: 'cs_ingest_slack_channel',
|
|
347
|
+
name: 'cs_ingest_slack_channel',
|
|
348
|
+
integrations: ['slack'],
|
|
349
|
+
consumes: ['slack.channel_scope'],
|
|
350
|
+
}),
|
|
351
|
+
]
|
|
352
|
+
const graph = buildCapabilityGraph(slackChainTools)
|
|
353
|
+
expect(graph.workflows).toHaveLength(1)
|
|
354
|
+
expect(graph.workflows[0].name).toBe('slack_ingestion')
|
|
355
|
+
expect(graph.workflows[0].steps).toEqual([
|
|
356
|
+
'cs_start_slack_auth',
|
|
357
|
+
'cs_discover_slack',
|
|
358
|
+
'cs_manage_channel_scope',
|
|
359
|
+
'cs_ingest_slack_channel',
|
|
360
|
+
])
|
|
361
|
+
expect(graph.workflows[0].description).toContain('start_slack_auth')
|
|
362
|
+
expect(graph.workflows[0].description).toContain('ingest_slack_channel')
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('extends Slack chain through cs_get_ingestion_status when isolated', () => {
|
|
366
|
+
const slackChainWithStatus = [
|
|
367
|
+
makeTool({
|
|
368
|
+
id: 'cs_start_slack_auth',
|
|
369
|
+
name: 'cs_start_slack_auth',
|
|
370
|
+
integrations: ['slack'],
|
|
371
|
+
produces: ['integration.connection'],
|
|
372
|
+
}),
|
|
373
|
+
makeTool({
|
|
374
|
+
id: 'cs_discover_slack',
|
|
375
|
+
name: 'cs_discover_slack',
|
|
376
|
+
integrations: ['slack'],
|
|
377
|
+
produces: ['slack.channel'],
|
|
378
|
+
consumes: ['integration.connection'],
|
|
379
|
+
}),
|
|
380
|
+
makeTool({
|
|
381
|
+
id: 'cs_manage_channel_scope',
|
|
382
|
+
name: 'cs_manage_channel_scope',
|
|
383
|
+
integrations: ['slack'],
|
|
384
|
+
produces: ['slack.channel_scope'],
|
|
385
|
+
consumes: ['slack.channel'],
|
|
386
|
+
}),
|
|
387
|
+
makeTool({
|
|
388
|
+
id: 'cs_ingest_slack_channel',
|
|
389
|
+
name: 'cs_ingest_slack_channel',
|
|
390
|
+
integrations: ['slack'],
|
|
391
|
+
produces: ['ingestion.job'],
|
|
392
|
+
consumes: ['slack.channel_scope'],
|
|
393
|
+
}),
|
|
394
|
+
makeTool({
|
|
395
|
+
id: 'cs_get_ingestion_status',
|
|
396
|
+
name: 'cs_get_ingestion_status',
|
|
397
|
+
produces: ['ingestion.job'],
|
|
398
|
+
consumes: ['ingestion.job'],
|
|
399
|
+
}),
|
|
400
|
+
]
|
|
401
|
+
const graph = buildCapabilityGraph(slackChainWithStatus)
|
|
402
|
+
|
|
403
|
+
expect(graph.workflows).toHaveLength(1)
|
|
404
|
+
// Chain stops at cs_ingest_slack_channel because cs_get_ingestion_status
|
|
405
|
+
// has 2 incoming edges (from ingest + self-loop)
|
|
406
|
+
expect(graph.workflows[0].steps).toContain('cs_start_slack_auth')
|
|
407
|
+
expect(graph.workflows[0].steps).toContain('cs_discover_slack')
|
|
408
|
+
expect(graph.workflows[0].steps).toContain('cs_manage_channel_scope')
|
|
409
|
+
expect(graph.workflows[0].steps).toContain('cs_ingest_slack_channel')
|
|
410
|
+
})
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('extracts domain from resource namespace prefix', () => {
|
|
414
|
+
const tools = [
|
|
415
|
+
makeTool({ id: 'a', name: 'a', produces: ['slack.channel'] }),
|
|
416
|
+
makeTool({ id: 'b', name: 'b', consumes: ['slack.channel'] }),
|
|
417
|
+
]
|
|
418
|
+
const graph = buildCapabilityGraph(tools)
|
|
419
|
+
expect(graph.edges[0].domain).toBe('slack')
|
|
420
|
+
})
|
|
421
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MCPToolDescriptor,
|
|
3
|
+
CapabilityGraph,
|
|
4
|
+
CapabilityGraphEdge,
|
|
5
|
+
ToolWorkflow,
|
|
6
|
+
} from './index'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build a capability graph from tool resource flow metadata.
|
|
10
|
+
*
|
|
11
|
+
* Pure function — no I/O, no database, no side effects.
|
|
12
|
+
* Derives edges from produces/consumes matching on MCPToolDescriptor.
|
|
13
|
+
*
|
|
14
|
+
* For each tool's `consumes` array, finds all tools whose `produces`
|
|
15
|
+
* includes that ResourceType and creates a CapabilityGraphEdge.
|
|
16
|
+
*/
|
|
17
|
+
export function buildCapabilityGraph(tools: MCPToolDescriptor[]): CapabilityGraph {
|
|
18
|
+
const edges: CapabilityGraphEdge[] = []
|
|
19
|
+
|
|
20
|
+
for (const consumer of tools) {
|
|
21
|
+
for (const resourceType of consumer.consumes ?? []) {
|
|
22
|
+
const producers = tools.filter(t => t.produces?.includes(resourceType))
|
|
23
|
+
for (const producer of producers) {
|
|
24
|
+
edges.push({
|
|
25
|
+
from: producer.id,
|
|
26
|
+
to: consumer.id,
|
|
27
|
+
resource: resourceType,
|
|
28
|
+
domain: resourceType.split('.')[0],
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const workflows = deriveWorkflows(tools, edges)
|
|
35
|
+
return { edges, workflows }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Derive named workflows from capability graph edges.
|
|
40
|
+
*
|
|
41
|
+
* Conservative: only linear chains with > 2 steps are reported.
|
|
42
|
+
* Branching nodes (multiple incoming or outgoing edges) break chains.
|
|
43
|
+
*
|
|
44
|
+
* Internal to capability-graph.ts — not exported.
|
|
45
|
+
*/
|
|
46
|
+
function deriveWorkflows(
|
|
47
|
+
tools: MCPToolDescriptor[],
|
|
48
|
+
edges: CapabilityGraphEdge[],
|
|
49
|
+
): ToolWorkflow[] {
|
|
50
|
+
// Build adjacency: tool ID → outgoing edges, tool ID → incoming edges
|
|
51
|
+
const outgoing = new Map<string, CapabilityGraphEdge[]>()
|
|
52
|
+
const incoming = new Map<string, CapabilityGraphEdge[]>()
|
|
53
|
+
|
|
54
|
+
for (const edge of edges) {
|
|
55
|
+
if (!outgoing.has(edge.from)) outgoing.set(edge.from, [])
|
|
56
|
+
outgoing.get(edge.from)!.push(edge)
|
|
57
|
+
if (!incoming.has(edge.to)) incoming.set(edge.to, [])
|
|
58
|
+
incoming.get(edge.to)!.push(edge)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Find chain start nodes: have outgoing edges, no incoming edges
|
|
62
|
+
const startNodes = [...outgoing.keys()].filter(
|
|
63
|
+
id => !incoming.has(id) || incoming.get(id)!.length === 0,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const workflows: ToolWorkflow[] = []
|
|
67
|
+
const visited = new Set<string>()
|
|
68
|
+
|
|
69
|
+
for (const start of startNodes) {
|
|
70
|
+
if (visited.has(start)) continue
|
|
71
|
+
|
|
72
|
+
const chain: string[] = [start]
|
|
73
|
+
visited.add(start)
|
|
74
|
+
let current = start
|
|
75
|
+
|
|
76
|
+
// Walk forward: follow single outgoing edge while next node has single incoming edge
|
|
77
|
+
while (true) {
|
|
78
|
+
const outs = outgoing.get(current)
|
|
79
|
+
if (!outs || outs.length !== 1) break
|
|
80
|
+
|
|
81
|
+
const next = outs[0].to
|
|
82
|
+
const ins = incoming.get(next)
|
|
83
|
+
if (!ins || ins.length !== 1) break
|
|
84
|
+
|
|
85
|
+
if (visited.has(next)) break
|
|
86
|
+
visited.add(next)
|
|
87
|
+
chain.push(next)
|
|
88
|
+
current = next
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Only emit workflows with > 2 steps
|
|
92
|
+
if (chain.length > 2) {
|
|
93
|
+
const toolMap = new Map(tools.map(t => [t.id, t]))
|
|
94
|
+
const chainTools = chain.map(id => toolMap.get(id)).filter(Boolean)
|
|
95
|
+
|
|
96
|
+
// Derive name from common integration or domain
|
|
97
|
+
const integrations = chainTools
|
|
98
|
+
.flatMap(t => t!.integrations ?? [])
|
|
99
|
+
.filter((v, i, a) => a.indexOf(v) === i)
|
|
100
|
+
const name =
|
|
101
|
+
integrations.length === 1
|
|
102
|
+
? `${integrations[0]}_ingestion`
|
|
103
|
+
: `workflow_${chain[0]}`
|
|
104
|
+
|
|
105
|
+
const steps = chain.map(s => s.replace(/^cs_/, ''))
|
|
106
|
+
const description = `Workflow: ${steps.join(' → ')}`
|
|
107
|
+
|
|
108
|
+
workflows.push({ name: name, description: description, steps: chain })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return workflows
|
|
113
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FailureContext — Structured Recovery for MCP Tool Failures
|
|
3
|
+
*
|
|
4
|
+
* When an MCP tool fails, the system emits a FailureContext that constrains
|
|
5
|
+
* the agent's next action to registered recovery actions only.
|
|
6
|
+
*
|
|
7
|
+
* INVARIANTS:
|
|
8
|
+
* - recoveryActions is exhaustive — the LLM cannot invent alternatives
|
|
9
|
+
* - 'cancel' is always implicitly available (not declared in recoveryActions)
|
|
10
|
+
* - No UI strings — labels/hints are the app layer's responsibility
|
|
11
|
+
* - depth tracks recovery chain length (1 = first failure)
|
|
12
|
+
* - At most one FailureContext is active at a time (no stacking)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Structured recovery context emitted when an MCP tool call fails.
|
|
17
|
+
* Constrains the agent's next action to registered recovery options.
|
|
18
|
+
*/
|
|
19
|
+
export interface FailureContext {
|
|
20
|
+
/** MCP tool name that failed (e.g., 'cs_post_slack_message') */
|
|
21
|
+
tool: string;
|
|
22
|
+
/** Machine-readable error code from the error code registry */
|
|
23
|
+
reason: string;
|
|
24
|
+
/** Whether the same call might succeed on retry */
|
|
25
|
+
retryable: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Exhaustive list of allowed recovery actions.
|
|
28
|
+
* The agent MUST only invoke tools from this list until resolved.
|
|
29
|
+
* Empty array = cancel-only (no tool-based recovery available).
|
|
30
|
+
*/
|
|
31
|
+
recoveryActions: RecoveryAction[];
|
|
32
|
+
/**
|
|
33
|
+
* Recovery chain depth. Starts at 1 on first failure.
|
|
34
|
+
* Incremented when a recovery action itself fails.
|
|
35
|
+
* When depth exceeds MAX_RECOVERY_DEPTH, only pure (read-only) tools remain available.
|
|
36
|
+
*/
|
|
37
|
+
depth: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A recovery action the agent may take.
|
|
42
|
+
*
|
|
43
|
+
* Discriminated union — 'retry' is distinct from calling the same tool.
|
|
44
|
+
* Retry implies: arguments may change, timing may change, reason awareness.
|
|
45
|
+
*/
|
|
46
|
+
export type RecoveryAction =
|
|
47
|
+
| { type: 'retry' }
|
|
48
|
+
| { type: 'tool'; tool: string };
|
package/src/mcp/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ResourceType } from './resources'
|
|
2
2
|
export type { ResourceType } from './resources'
|
|
3
|
+
export { buildCapabilityGraph } from './capability-graph'
|
|
4
|
+
export type { FailureContext, RecoveryAction } from './failure-context'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* MCP Tool Discovery Types
|
|
@@ -130,6 +132,7 @@ export type ToolIntegration = 'slack' | 'google' | 'zoom' | (string & {})
|
|
|
130
132
|
* Discovery uses: id, name, description, category
|
|
131
133
|
* Invocation uses: id, requiresConfirmation, invocationMode, effectClass
|
|
132
134
|
*/
|
|
135
|
+
// @vocabulary-exempt reason: MCPToolDescriptor is a single cohesive descriptor consumed by multiple repos; splitting would break consumers
|
|
133
136
|
export interface MCPToolDescriptor {
|
|
134
137
|
/** Unique identifier (matches MCP tool name, e.g., 'cs_help') */
|
|
135
138
|
id: string
|
|
@@ -67,6 +67,8 @@ export interface ConfirmationData {
|
|
|
67
67
|
risk: ConfirmationRiskLevel;
|
|
68
68
|
/** Lifecycle record ID — must exist before confirmation part is emitted */
|
|
69
69
|
executionId: string;
|
|
70
|
+
/** ISO 8601 deadline for confirmation window. Absent for legacy messages. */
|
|
71
|
+
expiresAt?: string;
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
/**
|