@orboto/mcp 0.222.2 → 0.224.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/dist/agent-context.js +49 -0
- package/dist/agent-context.test.js +45 -0
- package/dist/channel-start.js +52 -0
- package/dist/channel-start.test.js +83 -0
- package/dist/claude-context.js +8 -3
- package/dist/inbox-channel-acknowledged.test.js +19 -0
- package/dist/inbox-channel.js +11 -6
- package/dist/index.js +12 -2
- package/dist/orboto-client.js +14 -2
- package/dist/session-origin.js +17 -2
- package/dist/session-origin.test.js +12 -1
- package/dist/tail-labels.js +2 -0
- package/dist/tools/agent-coordination.js +12 -7
- package/dist/tools/agent-instructions.js +8 -5
- package/dist/tools/agent-messages.js +7 -1
- package/dist/tools/agent-presence-schema.js +11 -0
- package/dist/tools/agent-wakes.js +3 -1
- package/dist/tools/agent-wakes.test.js +8 -0
- package/dist/tools/agent-who.js +3 -1
- package/dist/tools/primer-facts.js +1 -1
- package/dist/tools/project-readiness-schema.js +4 -1
- package/dist/tools/query.js +49 -6
- package/dist/tools/query.test.js +69 -0
- package/dist/tools/session-start.js +62 -19
- package/dist/tools/session-start.test.js +109 -12
- package/dist/tools/update-project.js +3 -1
- package/dist/tools/update-project.test.js +10 -0
- package/dist/version-guard.test.js +3 -1
- package/package.json +1 -1
|
@@ -9,6 +9,15 @@ const AgentAccountRefSchema = z.object({ id: z.string().uuid(), name: z.string()
|
|
|
9
9
|
export const AgentInventoryKindSchema = z.enum(['agent', 'human']);
|
|
10
10
|
export const AgentConnectionTypeSchema = z.enum(['api_key', 'oauth', 'lane', 'external_token', 'live_events', 'web', 'channel']);
|
|
11
11
|
export const AgentInventoryConnectionSchema = z.object({ type: AgentConnectionTypeSchema, label: z.string() });
|
|
12
|
+
/** ORB-2584 - mirror of AgentSessionWakeSchema in @orboto/shared-schema agent-session-wake.ts. */
|
|
13
|
+
export const AgentSessionWakeSchema = z.enum(['channel', 'stream', 'lane', 'on_call']);
|
|
14
|
+
/** ORB-2584 - mirror of AGENT_WAKE_LABELS: what presence lines print for each wake. */
|
|
15
|
+
export const AGENT_WAKE_LABELS = {
|
|
16
|
+
channel: 'reachable by channel',
|
|
17
|
+
stream: 'reachable by inbox stream',
|
|
18
|
+
lane: 'reachable as a lane',
|
|
19
|
+
on_call: 'reads mail only on its own calls',
|
|
20
|
+
};
|
|
12
21
|
export const AgentInventoryProjectSchema = z.object({ id: z.string().uuid(), key: z.string(), name: z.string() });
|
|
13
22
|
export const AgentInventoryEntrySchema = z.object({
|
|
14
23
|
userId: z.string().uuid(),
|
|
@@ -25,6 +34,8 @@ export const AgentInventoryEntrySchema = z.object({
|
|
|
25
34
|
lane: z.object({ id: z.string().uuid(), name: z.string(), role: z.string(), paused: z.boolean() }).nullable(),
|
|
26
35
|
projects: z.array(AgentInventoryProjectSchema),
|
|
27
36
|
connections: z.array(AgentInventoryConnectionSchema),
|
|
37
|
+
/** ORB-2584 - whether mail wakes the session (channel, stream, lane) or it reads mail only on its own calls (`on_call`). */
|
|
38
|
+
wake: AgentSessionWakeSchema.optional(),
|
|
28
39
|
workingOnTicket: z.object({ id: z.string().uuid(), key: z.string().nullable(), title: z.string(), projectKey: z.string().nullable() }).nullable(),
|
|
29
40
|
workSessions: z.array(z.object({
|
|
30
41
|
ticketId: z.string().uuid(), ticketKey: z.string().nullable(), role: z.string(), leaseUntil: z.string(),
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* did with it and what it cost.
|
|
4
4
|
*/
|
|
5
5
|
import { z } from 'zod';
|
|
6
|
+
import { AGENT_WAKE_LABELS } from './agent-presence-schema.js';
|
|
6
7
|
export const agentWakesToolConfig = {
|
|
7
8
|
title: 'Wake ledger',
|
|
8
9
|
description: 'The wake ledger: who woke this account\'s sessions and at what cost. It names the visibility rule that delivered the message (addressed | scope_project | scope_ticket | broadcast | unscoped_session | account_view), what the woken session did with it (handled | not_mine | obsolete | duplicate | expired | pending) and what the wake cost in context tokens. `stats: true` returns delivery precision per woken session and per sender over `hours` (operator read, needs admin:agents:read); `wakes`, `precision` and the median latency count deliveries (`channel` | `follow` | `poll`) only, `backlogSeen` is the separate count of `nudge` rows - a backlog sighting, not a wake. Without `stats` it returns the ledger rows of your own account, newest first. Use it to prove a misrouting sender instead of describing it in prose.',
|
|
@@ -21,7 +22,8 @@ function statsLine(e) {
|
|
|
21
22
|
const precision = e.precision === null ? 'unjudged' : `${Math.round(e.precision * 100)}%`;
|
|
22
23
|
const tokens = e.wrongWakeContextTokens > 0 ? `, wrong wakes cost ${e.wrongWakeContextTokens} tokens` : '';
|
|
23
24
|
const backlog = e.backlogSeen > 0 ? `, ${e.backlogSeen} backlog seen` : '';
|
|
24
|
-
|
|
25
|
+
const wake = e.wake ? `, now ${AGENT_WAKE_LABELS[e.wake]}` : '';
|
|
26
|
+
return `- ${e.label}: ${e.wakes} wakes, precision ${precision}, median ${e.medianLatencyMs ?? '?'} ms, top rule ${e.topRule ?? '-'}${tokens}${backlog}${wake}`;
|
|
25
27
|
}
|
|
26
28
|
export function makeAgentWakesHandler(client) {
|
|
27
29
|
return async (args) => {
|
|
@@ -25,6 +25,14 @@ describe('orboto_agent_wakes', () => {
|
|
|
25
25
|
expect(text).toContain('- worker (ef56ab78): 1 wakes, precision unjudged, median ? ms, top rule -');
|
|
26
26
|
expect(text).toContain('Senders:\n- (none)');
|
|
27
27
|
});
|
|
28
|
+
it('ORB-2584 - names how mail reaches each session now', async () => {
|
|
29
|
+
const { client } = clientReturning({
|
|
30
|
+
hours: 24,
|
|
31
|
+
sessions: [{ label: 'coordinator (aa11bb22)', wakes: 2, precision: null, medianLatencyMs: null, topRule: null, wrongWakeContextTokens: 0, backlogSeen: 0, wake: 'on_call' }],
|
|
32
|
+
senders: [],
|
|
33
|
+
});
|
|
34
|
+
expect(textOf(await makeAgentWakesHandler(client)({ stats: true }))).toContain('- coordinator (aa11bb22): 2 wakes, precision unjudged, median ? ms, top rule -, now reads mail only on its own calls');
|
|
35
|
+
});
|
|
28
36
|
it('says so when nothing woke anyone', async () => {
|
|
29
37
|
const { client } = clientReturning({ hours: 24, sessions: [], senders: [] });
|
|
30
38
|
expect(textOf(await makeAgentWakesHandler(client)({ stats: true }))).toContain('- (no wakes)');
|
package/dist/tools/agent-who.js
CHANGED
|
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import { AgentSessionRoleSchema } from './agent-session-scope.js';
|
|
4
4
|
import { ownerLine } from './responsibilities.js';
|
|
5
5
|
import { originText } from './agent-coordination.js';
|
|
6
|
+
import { AGENT_WAKE_LABELS } from './agent-presence-schema.js';
|
|
6
7
|
export const agentWhoToolConfig = {
|
|
7
8
|
title: 'Who receives agent mail',
|
|
8
9
|
description: 'Live agent sessions and the role addresses (integrator@KEY) they answer to. Each row names account, role, declared projects, last seen, wake channel and current ticket; address a role from here, never a session id copied from a log. `for` (free text) names the topic owner and keeps its sessions; `state` keeps one state (orphaned: only another process touches it).',
|
|
@@ -18,7 +19,8 @@ export const agentWhoToolConfig = {
|
|
|
18
19
|
/** One directory row as a line: address, account, session, projects, channel, ticket. */
|
|
19
20
|
export function whoLine(s) {
|
|
20
21
|
const address = s.addresses.join(', ') || (s.role ? `${s.role} (no project)` : 'no scope');
|
|
21
|
-
const
|
|
22
|
+
const announced = s.channel.announced ? `${s.channel.announced}${s.channel.online ? ' online' : ' offline'}` : 'no channel';
|
|
23
|
+
const channel = s.wake === 'channel' || !s.wake ? announced : AGENT_WAKE_LABELS[s.wake];
|
|
22
24
|
const ticket = s.currentTicket ? `; on ${s.currentTicket.key ?? s.currentTicket.title}` : '';
|
|
23
25
|
const host = s.origin ? originText(s) : s.host ? `, host ${s.host}` : '';
|
|
24
26
|
const shared = s.sharedAddresses?.length ? `; also held by another live session: ${s.sharedAddresses.join(', ')}` : '';
|
|
@@ -247,7 +247,7 @@ export function makePrimerFactSupersedeHandler(client) {
|
|
|
247
247
|
}
|
|
248
248
|
export const primerFactVerifyToolConfig = {
|
|
249
249
|
title: 'Verify an agent-observed primer fact',
|
|
250
|
-
description: 'Promote an agent-observed fact to verified.
|
|
250
|
+
description: 'Promote an agent-observed fact to verified. A person verifies: a bot account or an agent key gets 403 errors.primer_facts.verify_needs_person, so an agent proposes and never confirms. Removes the (observed) marker from the rendered primer and stamps verifiedBy / verifiedAt.',
|
|
251
251
|
inputSchema: z.object({
|
|
252
252
|
factId: z.string().min(1).describe('UUID of the fact to verify.'),
|
|
253
253
|
}).shape,
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
export const ProjectReadinessStatusSchema = z.enum(['ok', 'missing', 'n/a']);
|
|
3
3
|
export const ProjectReadinessItemIdSchema = z.enum(['project_record', 'human_manager', 'milestones', 'git_connection', 'doc_primer', 'instructions', 'tech_stack', 'commands', 'lane_assignment', 'acceptance', 'spec_settings', 'sentry_intake']);
|
|
4
|
+
const ProjectReadinessFixSchema = z.object({ method: z.string(), route: z.string(), mcpTool: z.string(), cliCommand: z.string(), settingsPath: z.string() });
|
|
4
5
|
export const ProjectReadinessItemSchema = z.object({
|
|
5
6
|
id: ProjectReadinessItemIdSchema,
|
|
6
7
|
status: ProjectReadinessStatusSchema,
|
|
7
8
|
reason: z.string(),
|
|
8
9
|
checked: z.number().int().nonnegative(),
|
|
9
10
|
missing: z.number().int().nonnegative(),
|
|
10
|
-
|
|
11
|
+
detail: z.string().optional().describe('The stored decision text behind the reason, e.g. why a project needs no headless lane.'),
|
|
12
|
+
fix: ProjectReadinessFixSchema,
|
|
13
|
+
alternatives: z.array(ProjectReadinessFixSchema).optional().describe('Other ways to resolve the item, offered next to fix.'),
|
|
11
14
|
});
|
|
12
15
|
export const ProjectReadinessSchema = z.object({
|
|
13
16
|
projectId: z.string().uuid(), projectKey: z.string(), ready: z.boolean(), language: z.string(),
|
package/dist/tools/query.js
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ORB-273 Phase F - `orboto_query`.
|
|
3
3
|
*
|
|
4
|
-
* @see ORB-531
|
|
4
|
+
* @see ORB-531, ORB-2581
|
|
5
5
|
*/
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
+
import { OrbotoApiError } from '../orboto-client.js';
|
|
7
8
|
import { ticketLine, agentTicketListRow } from './shared.js';
|
|
8
9
|
export const queryToolConfig = {
|
|
9
10
|
title: 'Run an OQL / JQL query',
|
|
10
|
-
description: 'Run a typed OQL (or Jira-shaped JQL) query
|
|
11
|
+
description: 'Run a typed OQL (or Jira-shaped JQL) query and return matching tickets. '
|
|
11
12
|
+ 'OQL grammar: `[FIND tickets WHERE] field operator value [AND|OR ...] [ORDER BY ...] [LIMIT n]` (the frame is an optional alias). '
|
|
12
13
|
+ 'Supported fields: key, status, statusName, statusCategory, priority, type, assignee, reporter, reporterType, assigneeType, labels, milestone, version, project, isPrivate, dueDate, startDate, createdAt, updatedAt, closedAt, estimatedTimeMinutes, loggedMinutes, parentKey. '
|
|
13
|
-
+ 'reporterType / assigneeType are guest|internal
|
|
14
|
+
+ 'reporterType / assigneeType are guest|internal (`reporterType = guest OR assigneeType = guest` finds all guest tickets). '
|
|
14
15
|
+ 'Operators: = != < <= > >= ~ !~ IN IS [NOT] NULL IS [NOT] EMPTY. '
|
|
15
16
|
+ 'Functions: currentUser(), now(), startOfWeek(), endOfWeek(), startOfMonth(), endOfMonth(), daysAgo(n). '
|
|
16
17
|
+ 'Examples: '
|
|
17
18
|
+ '`project = ORB AND assignee = currentUser() AND statusCategory != done`; '
|
|
18
19
|
+ '`priority IN (blocker, high) AND dueDate <= now() ORDER BY dueDate ASC LIMIT 25`; '
|
|
19
20
|
+ '`labels = "bug" AND createdAt >= daysAgo(7)`. '
|
|
20
|
-
+ '
|
|
21
|
+
+ 'Use it when orboto_list_tickets / orboto_my_tickets cannot express the filter. '
|
|
22
|
+
+ 'A 429 is retried at most twice, then returned with structuredContent rateLimited, retryAfterSeconds, limitPerMinute.',
|
|
21
23
|
inputSchema: z.object({
|
|
22
24
|
oql: z.string().min(1).max(8000).describe('The query string.'),
|
|
23
25
|
syntax: z.enum(['oql', 'jql']).default('oql').describe('"jql" accepts Jira-flavoured syntax.'),
|
|
@@ -27,7 +29,35 @@ export const queryToolConfig = {
|
|
|
27
29
|
}).shape,
|
|
28
30
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
29
31
|
};
|
|
30
|
-
|
|
32
|
+
/** ORB-2581 - `orboto_query` waits out at most two 429s of up to 20 seconds each, then answers with the retry hint. */
|
|
33
|
+
export const QUERY_RETRY = { maxRetries: 2, maxWaitSeconds: 20 };
|
|
34
|
+
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
35
|
+
function rateLimitDetails(err) {
|
|
36
|
+
let params = {};
|
|
37
|
+
try {
|
|
38
|
+
params = JSON.parse(err.body).errorParams ?? {};
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
params = {};
|
|
42
|
+
}
|
|
43
|
+
const fromBody = typeof params.retryAfterSeconds === 'number' ? params.retryAfterSeconds : null;
|
|
44
|
+
return {
|
|
45
|
+
retryAfterSeconds: Math.max(0, err.retryAfterSeconds ?? fromBody ?? 1),
|
|
46
|
+
limitPerMinute: typeof params.limitPerMinute === 'number' ? params.limitPerMinute : null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function rateLimitedResult(details, attempts) {
|
|
50
|
+
const limit = details.limitPerMinute !== null ? ` (${details.limitPerMinute} per minute)` : '';
|
|
51
|
+
return {
|
|
52
|
+
isError: true,
|
|
53
|
+
content: [{
|
|
54
|
+
type: 'text',
|
|
55
|
+
text: `Rate limited: this account's OQL query limit${limit} is used up. Retry after ${details.retryAfterSeconds} seconds (tried ${attempts} times).`,
|
|
56
|
+
}],
|
|
57
|
+
structuredContent: { rateLimited: true, retryAfterSeconds: details.retryAfterSeconds, limitPerMinute: details.limitPerMinute, attempts },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function makeQueryHandler(client, sleep = realSleep) {
|
|
31
61
|
return async (input) => {
|
|
32
62
|
const body = {
|
|
33
63
|
oql: input.oql,
|
|
@@ -35,7 +65,20 @@ export function makeQueryHandler(client) {
|
|
|
35
65
|
cursor: input.cursor,
|
|
36
66
|
limit: input.limit ?? 25,
|
|
37
67
|
};
|
|
38
|
-
|
|
68
|
+
let page;
|
|
69
|
+
for (let attempt = 1; page === undefined; attempt += 1) {
|
|
70
|
+
try {
|
|
71
|
+
page = await client.post('/query', body);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (!(err instanceof OrbotoApiError) || err.status !== 429)
|
|
75
|
+
throw err;
|
|
76
|
+
const details = rateLimitDetails(err);
|
|
77
|
+
if (attempt > QUERY_RETRY.maxRetries || details.retryAfterSeconds > QUERY_RETRY.maxWaitSeconds)
|
|
78
|
+
return rateLimitedResult(details, attempt);
|
|
79
|
+
await sleep(details.retryAfterSeconds * 1000);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
39
82
|
const text = page.items.length === 0
|
|
40
83
|
? 'No tickets matched.'
|
|
41
84
|
: page.items.map((t) => `- ${ticketLine(t)}`).join('\n');
|
package/dist/tools/query.test.js
CHANGED
|
@@ -86,6 +86,75 @@ describe('tools/query', () => {
|
|
|
86
86
|
}));
|
|
87
87
|
});
|
|
88
88
|
});
|
|
89
|
+
import { metricsToolName } from '../tail-labels.js';
|
|
90
|
+
import { parseRetryAfter } from '../orboto-client.js';
|
|
91
|
+
import { QUERY_RETRY } from './query.js';
|
|
92
|
+
function rateLimited(retryAfter, errorParams = {}) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false, status: 429, statusText: 'Too Many Requests',
|
|
95
|
+
headers: new Headers(retryAfter === null ? {} : { 'retry-after': retryAfter }),
|
|
96
|
+
text: async () => JSON.stringify({ error: 'Rate limit exceeded', errorKey: 'errors.common.rate_limited', errorParams }),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function ok(json) {
|
|
100
|
+
return { ok: true, status: 200, statusText: 'OK', headers: new Headers(), json: async () => json };
|
|
101
|
+
}
|
|
102
|
+
describe('tools/query - 429 and Retry-After (ORB-2581)', () => {
|
|
103
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
|
|
104
|
+
it('waits the Retry-After seconds and retries until the query answers', async () => {
|
|
105
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
|
106
|
+
.mockResolvedValueOnce(rateLimited('3'))
|
|
107
|
+
.mockResolvedValueOnce(ok({ items: [], nextCursor: null }));
|
|
108
|
+
const sleep = vi.fn(async () => { });
|
|
109
|
+
const result = await makeQueryHandler(client, sleep)({ oql: 'project = ORB' });
|
|
110
|
+
expect(sleep).toHaveBeenCalledWith(3000);
|
|
111
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
112
|
+
expect(result.isError).toBeUndefined();
|
|
113
|
+
expect(result.content[0]).toEqual({ type: 'text', text: 'No tickets matched.' });
|
|
114
|
+
});
|
|
115
|
+
it('stops after the bounded retries and answers with the retry hint', async () => {
|
|
116
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(rateLimited('5', { retryAfterSeconds: 5, limitPerMinute: 60 }));
|
|
117
|
+
const sleep = vi.fn(async () => { });
|
|
118
|
+
const result = await makeQueryHandler(client, sleep)({ oql: 'project = ORB' });
|
|
119
|
+
expect(fetchSpy).toHaveBeenCalledTimes(QUERY_RETRY.maxRetries + 1);
|
|
120
|
+
expect(sleep).toHaveBeenCalledTimes(QUERY_RETRY.maxRetries);
|
|
121
|
+
expect(result.isError).toBe(true);
|
|
122
|
+
expect(result.content[0].text).toContain('Retry after 5 seconds');
|
|
123
|
+
expect(result.structuredContent).toEqual({ rateLimited: true, retryAfterSeconds: 5, limitPerMinute: 60, attempts: QUERY_RETRY.maxRetries + 1 });
|
|
124
|
+
});
|
|
125
|
+
it('never waits longer than the cap: a long Retry-After answers at once', async () => {
|
|
126
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(rateLimited(String(QUERY_RETRY.maxWaitSeconds + 1)));
|
|
127
|
+
const sleep = vi.fn(async () => { });
|
|
128
|
+
const result = await makeQueryHandler(client, sleep)({ oql: 'project = ORB' });
|
|
129
|
+
expect(sleep).not.toHaveBeenCalled();
|
|
130
|
+
expect(result.structuredContent).toMatchObject({ rateLimited: true, retryAfterSeconds: QUERY_RETRY.maxWaitSeconds + 1, attempts: 1 });
|
|
131
|
+
});
|
|
132
|
+
it('reads the wait from errorParams when a proxy dropped the header', async () => {
|
|
133
|
+
vi.spyOn(globalThis, 'fetch')
|
|
134
|
+
.mockResolvedValueOnce(rateLimited(null, { retryAfterSeconds: 7 }))
|
|
135
|
+
.mockResolvedValueOnce(ok({ items: [], nextCursor: null }));
|
|
136
|
+
const sleep = vi.fn(async () => { });
|
|
137
|
+
await makeQueryHandler(client, sleep)({ oql: 'project = ORB' });
|
|
138
|
+
expect(sleep).toHaveBeenCalledWith(7000);
|
|
139
|
+
});
|
|
140
|
+
it('passes every other error through unchanged', async () => {
|
|
141
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
142
|
+
ok: false, status: 400, statusText: 'Bad Request', headers: new Headers(), text: async () => '{"errorKey":"errors.oql.parse"}',
|
|
143
|
+
});
|
|
144
|
+
await expect(makeQueryHandler(client, vi.fn(async () => { }))({ oql: '((' })).rejects.toMatchObject({ status: 400 });
|
|
145
|
+
});
|
|
146
|
+
it('parses delta-seconds and HTTP dates', () => {
|
|
147
|
+
expect(parseRetryAfter('12')).toBe(12);
|
|
148
|
+
expect(parseRetryAfter(null)).toBeNull();
|
|
149
|
+
expect(parseRetryAfter('soon')).toBeNull();
|
|
150
|
+
expect(parseRetryAfter(new Date(Date.UTC(2026, 9, 18, 12, 0, 30)).toUTCString(), Date.UTC(2026, 9, 18, 12, 0, 0))).toBe(30);
|
|
151
|
+
});
|
|
152
|
+
it('labels the query limit tail routes for metrics', () => {
|
|
153
|
+
const id = '00000000-0000-4000-8000-000000000000';
|
|
154
|
+
expect(metricsToolName('orboto_api_call', { method: 'GET', path: `/admin/agents/identities/${id}/query-rate-limit` })).toBe('orboto_agent_query_rate_limit');
|
|
155
|
+
expect(metricsToolName('orboto_api_call', { method: 'PUT', path: `/admin/agents/identities/${id}/query-rate-limit` })).toBe('orboto_agent_query_rate_limit');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
89
158
|
import { agentTicketListRow } from './shared.js';
|
|
90
159
|
describe('agentTicketListRow (ORB-1699)', () => {
|
|
91
160
|
const FULL = {
|
|
@@ -18,11 +18,12 @@ import { channelStartLines } from '../inbox-channel.js';
|
|
|
18
18
|
import { rememberDeclaredScope } from '../session-scope-memory.js';
|
|
19
19
|
import { declareEnvProjectScope, envProjectScope } from '../project-scope.js';
|
|
20
20
|
import { agentHeadText } from '../agent-head.js';
|
|
21
|
+
import { agentContextKey } from '../agent-context.js';
|
|
21
22
|
export const sessionStartToolConfig = {
|
|
22
23
|
title: 'Load the rules you must follow + re-orient',
|
|
23
|
-
description: 'THE canonical way to LOAD the binding workspace rules you must follow as an agent. Run it
|
|
24
|
-
+ '**Parameters.** `rulesOnly: true` returns ONLY the complete rules text
|
|
25
|
-
+ '
|
|
24
|
+
description: 'THE canonical way to LOAD the binding workspace rules you must follow as an agent. Run it FIRST in a session and AFTER every context compaction. Returns the rules in full (above the full-text bound: one title per rule plus every must-read rule in full, the rest via `rulesOnly: true`); a compact "unchanged" ack only on a repeat inside the same agent context. Also returns your in-progress tickets (LANDED, IDLE = a linked commit but no move for days: finished work never handed to review), your timer and unhealthy git connections. Not orboto_list_agent_instructions - that one edits rule blocks for admins. Registers this session (a heartbeat on its instance row), otherwise read-only. '
|
|
25
|
+
+ '**Parameters.** `rulesOnly: true` returns ONLY the complete rules text, never truncated. `projectId` adds that project\'s rules. `ticketKey` ("ACME-42") bundles that ticket\'s primer, detail, dependencies, checklists, git health and other agent sessions. `forceRules: true` returns the full text inline even above the bound or after an ack - use it whenever the rules are NOT in your context. `agentKind` (coding, orchestrator, reviewer, runner) and `modelTier` (frontier, standard, small) target rule blocks and per-tier text. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility; session.ref is what peers pass as toSessionRef (ORB-2136). '
|
|
26
|
+
+ 'Also carries the Knowledge block (ORB-2225: the wiki index of your declared projects and the Operations wiki; ask with orboto_knowledge_ask before a person, file durable facts with orboto_knowledge_add) and the Responsibilities block (ORB-2542: who owns the topics of your projects, orboto_who before agent mail). Right after the rules come Where knowledge lives (ORB-2575: rules bind behaviour, project facts hold fixed decisions, the wiki the long tail; a decision goes to a fact proposal, orboto_primer_fact_add with observed: true) and the Project facts of your declared projects.',
|
|
26
27
|
inputSchema: z.object({
|
|
27
28
|
projectId: z.string().uuid().optional().describe('Also load this project\'s rules.'),
|
|
28
29
|
ticketKey: z.string().min(3).optional().describe('Ticket key ("ACME-42") - bundles that ticket\'s full context.'),
|
|
@@ -133,6 +134,22 @@ async function buildTicketBundle(client, ticketKey) {
|
|
|
133
134
|
},
|
|
134
135
|
};
|
|
135
136
|
}
|
|
137
|
+
/** ORB-2575 - the facts block stays small; the rest is one expand away, like the Knowledge block. */
|
|
138
|
+
export const PROJECT_FACTS_BLOCK_CHARS = 3000;
|
|
139
|
+
export function projectFactsLines(text, owner = PROCESS_OWNER) {
|
|
140
|
+
if (text.length <= PROJECT_FACTS_BLOCK_CHARS)
|
|
141
|
+
return { lines: text.split('\n') };
|
|
142
|
+
const cut = text.slice(0, PROJECT_FACTS_BLOCK_CHARS);
|
|
143
|
+
const kept = cut.slice(0, Math.max(cut.lastIndexOf('\n'), 0));
|
|
144
|
+
const handle = storePayload('orboto_session_start', {
|
|
145
|
+
structuredContent: { projectFacts: text }, text,
|
|
146
|
+
omitted: [{ path: 'projectFacts', kind: 'string', omittedChars: text.length - kept.length }],
|
|
147
|
+
}, Date.now(), owner);
|
|
148
|
+
return {
|
|
149
|
+
handle,
|
|
150
|
+
lines: [...kept.split('\n'), `(Project facts cut at ${kept.length} of ${text.length} characters: orboto_response_expand { handle: "${handle}", path: "projectFacts" }, or orboto_get_project_primer.)`],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
136
153
|
/** ORB-2225 - the Knowledge block stays small; the rest is one expand away. */
|
|
137
154
|
export const KNOWLEDGE_BLOCK_CHARS = 1500;
|
|
138
155
|
export function knowledgeLines(text, owner = PROCESS_OWNER) {
|
|
@@ -164,7 +181,8 @@ export function identityLine(registration) {
|
|
|
164
181
|
return `This scope stays with this session across reconnects and restarts; declare it again only to change it.${kept}`;
|
|
165
182
|
}
|
|
166
183
|
export function makeSessionStartHandler(client, opts = {}) {
|
|
167
|
-
|
|
184
|
+
const currentContext = opts.agentContext ?? (() => agentContextKey());
|
|
185
|
+
let delivered;
|
|
168
186
|
return async (input = {}, extra) => {
|
|
169
187
|
const instanceToken = mcpInstanceToken(undefined, extra);
|
|
170
188
|
const rulesParams = new URLSearchParams();
|
|
@@ -172,14 +190,15 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
172
190
|
rulesParams.set('projectId', input.projectId);
|
|
173
191
|
applyAgentProfile(rulesParams, { agentKind: input.agentKind, modelTier: input.modelTier });
|
|
174
192
|
const wantsFullRules = input.forceRules === true || input.rulesOnly === true;
|
|
175
|
-
|
|
176
|
-
|
|
193
|
+
const context = currentContext();
|
|
194
|
+
if (context && delivered?.context === context && !wantsFullRules)
|
|
195
|
+
rulesParams.set('knownRulesHash', delivered.hash);
|
|
177
196
|
const rulesQs = rulesParams.toString();
|
|
178
197
|
const rulesPath = rulesQs ? `/agent-instructions?${rulesQs}` : '/agent-instructions';
|
|
179
198
|
if (input.rulesOnly) {
|
|
180
199
|
const rules = await loadRequiredRules(client, rulesPath);
|
|
181
|
-
if (rules.rulesHash)
|
|
182
|
-
|
|
200
|
+
if (context && rules.rulesHash)
|
|
201
|
+
delivered = { context, hash: rules.rulesHash };
|
|
183
202
|
const text = rules.instructions ?? '';
|
|
184
203
|
return {
|
|
185
204
|
_meta: { [PROTECT_TEXT_META]: true },
|
|
@@ -201,7 +220,10 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
201
220
|
},
|
|
202
221
|
};
|
|
203
222
|
}
|
|
204
|
-
const toRegistration = (r) => (r && typeof r.sessionId === 'string' ? {
|
|
223
|
+
const toRegistration = (r) => (r && typeof r.sessionId === 'string' ? {
|
|
224
|
+
sessionId: r.sessionId, scope: r.scope ?? null, reconnects: r.reconnects, lastReconnectAt: r.lastReconnectAt,
|
|
225
|
+
wake: r.wake, scopeExpired: r.scopeExpired ?? null, notes: Array.isArray(r.notes) ? r.notes.filter((n) => typeof n === 'string') : [],
|
|
226
|
+
} : null);
|
|
205
227
|
const host = localSessionHost(extra);
|
|
206
228
|
let registration = await client.post('/v1/agent/heartbeat', { ...(input.scope !== undefined ? { scope: input.scope } : {}), ...(host ? { clientInfo: { host } } : {}) }, { instanceToken })
|
|
207
229
|
.then(toRegistration)
|
|
@@ -211,7 +233,7 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
211
233
|
if (input.scope === undefined && registration && !registration.scope && envProjectScope()) {
|
|
212
234
|
registration = toRegistration(await declareEnvProjectScope(client, instanceToken)) ?? registration;
|
|
213
235
|
}
|
|
214
|
-
const [me, rules, assigned, timer, inboxRaw, knowledge, owners] = await Promise.all([
|
|
236
|
+
const [me, rules, assigned, timer, inboxRaw, knowledge, owners, projectFacts] = await Promise.all([
|
|
215
237
|
client.get('/users/me').catch(() => null),
|
|
216
238
|
loadRequiredRules(client, rulesPath, rulesParams.get('knownRulesHash') ?? undefined),
|
|
217
239
|
client.get('/users/me/assigned-tickets?statuses=IN_PROGRESS,IN_REVIEW&limit=20').catch(() => ({ items: [] })),
|
|
@@ -225,6 +247,9 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
225
247
|
client.get('/responsibilities/block', { instanceToken })
|
|
226
248
|
.then((r) => (typeof r?.text === 'string' && r.text ? { text: r.text, scoped: Array.isArray(r.entries) ? r.entries.length : 0, total: r.total ?? 0 } : null))
|
|
227
249
|
.catch(() => null),
|
|
250
|
+
client.get('/project-facts', { instanceToken })
|
|
251
|
+
.then((r) => (typeof r?.text === 'string' && r.text.trim() && Array.isArray(r.projects) && Array.isArray(r.unknown) ? r : null))
|
|
252
|
+
.catch(() => null),
|
|
228
253
|
]);
|
|
229
254
|
const pendingMessages = inboxRaw.map((m) => ({ id: m.id, fromUserId: m.fromUserId, from: m.from?.label ?? m.fromUserId, kind: m.kind, subject: m.subject, createdAt: m.createdAt }));
|
|
230
255
|
const tickets = Array.isArray(assigned) ? assigned : (assigned?.items ?? []);
|
|
@@ -256,18 +281,18 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
256
281
|
if (me?.workspaceLocale || me?.locale)
|
|
257
282
|
lines.push(`Write tickets / comments / docs in: ${me.workspaceLocale ?? me.locale}.`);
|
|
258
283
|
const rulesText = rules.instructions ?? '';
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const deliverIndex = !rules.rulesUnchanged && input.forceRules !== true && rulesIndex.length > 0 && rulesText.length > 0;
|
|
284
|
+
const indexEntries = Array.isArray(rules.rulesIndex) ? rules.rulesIndex.filter((e) => typeof e?.title === 'string' && e.title.length > 0) : [];
|
|
285
|
+
const rulesIndex = indexEntries.map((e) => e.title);
|
|
286
|
+
const mustRead = indexEntries.filter((e) => e.mustRead === true && typeof e.body === 'string' && e.body.trim().length > 0);
|
|
287
|
+
const deliverIndex = !rules.rulesUnchanged && input.forceRules !== true && rules.rulesDelivery === 'index' && rulesIndex.length > 0 && rulesText.length > 0;
|
|
263
288
|
const deliverFull = !rules.rulesUnchanged && !deliverIndex && rulesText.length > 0;
|
|
264
289
|
const rulesChars = rules.rulesChars ?? rulesText.length;
|
|
265
290
|
const HOW_TO_READ_RULES = 'Call orboto_session_start { rulesOnly: true } for the complete rule text (one call, never truncated). '
|
|
266
|
-
+ 'Do that whenever you do not already hold this exact rulesHash
|
|
291
|
+
+ 'Do that whenever you do not already hold this exact rulesHash.';
|
|
267
292
|
let rulesHandle;
|
|
268
293
|
lines.push('', '## Working rules - follow these');
|
|
269
294
|
if (rules.rulesUnchanged) {
|
|
270
|
-
lines.push(`Unchanged since this
|
|
295
|
+
lines.push(`Unchanged since this agent context received them in full (hash ${rules.rulesHash}), so they were left out to save context.`, 'If the rules are NOT in your context right now, call this tool again with rulesOnly=true (rules alone) or forceRules=true (rules plus this digest) and read them in full. Do not proceed on a half-remembered rule set.');
|
|
271
296
|
}
|
|
272
297
|
else if (deliverIndex) {
|
|
273
298
|
rulesHandle = storePayload('orboto_session_start', {
|
|
@@ -275,11 +300,16 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
275
300
|
text: rules.instructions ?? '',
|
|
276
301
|
omitted: [{ path: 'rules', kind: 'string', omittedChars: rulesText.length }],
|
|
277
302
|
}, Date.now(), client);
|
|
278
|
-
lines.push(`${rulesIndex.length} rule(s) bind you (${rulesChars} characters, hash ${rules.rulesHash}).
|
|
303
|
+
lines.push(`${rulesIndex.length} rule(s) bind you (${rulesChars} characters, hash ${rules.rulesHash}). The set is above the full-text bound, so the titles are printed and the ${mustRead.length} must-read rule(s) in full.`, `${HOW_TO_READ_RULES} Read any rule below whose title touches what you are about to do BEFORE you do it. (orboto_response_expand { handle: "${rulesHandle}", path: "rules" } serves the same text from this process for 15 minutes.)`, ...indexEntries.map((e, i) => `${i + 1}. ${e.title}${mustRead.includes(e) ? ' (must read, in full below)' : ''}`));
|
|
304
|
+
for (const e of mustRead)
|
|
305
|
+
lines.push('', `### Must read: ${e.title}`, String(e.body).trim());
|
|
279
306
|
}
|
|
280
307
|
else {
|
|
281
308
|
lines.push(rulesText || '(no workspace rules configured)');
|
|
282
309
|
}
|
|
310
|
+
const factsBlock = projectFacts ? projectFactsLines(projectFacts.text.trim(), client) : null;
|
|
311
|
+
if (factsBlock)
|
|
312
|
+
lines.push('', ...factsBlock.lines);
|
|
283
313
|
lines.push('', '## Your in-progress work');
|
|
284
314
|
if (tickets.length === 0)
|
|
285
315
|
lines.push('No tickets currently assigned to you - claim or create one before you start coding.');
|
|
@@ -316,6 +346,8 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
316
346
|
lines.push(`Address form: peers reach this session and every other ${registration.scope.role} of the project with orboto_agent_notify { to: "${roleAddresses[0]}" } (${roleAddresses.join(', ')}); address a role, never a session id copied from a log.`);
|
|
317
347
|
if (registration?.scope)
|
|
318
348
|
lines.push(identityLine(registration));
|
|
349
|
+
for (const note of registration?.notes ?? [])
|
|
350
|
+
lines.push(note);
|
|
319
351
|
if (opts.channel)
|
|
320
352
|
lines.push('', ...channelStartLines());
|
|
321
353
|
if (pendingMessages.length > 0) {
|
|
@@ -336,7 +368,7 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
336
368
|
rulesUnchanged: rules.rulesUnchanged === true,
|
|
337
369
|
rulesDelivery: rules.rulesUnchanged ? 'ack' : deliverIndex ? 'index' : 'full',
|
|
338
370
|
...(deliverIndex
|
|
339
|
-
? { rulesIndex, rulesChars, rulesHandle, rulesHowToRead: HOW_TO_READ_RULES }
|
|
371
|
+
? { rulesIndex, rulesChars, rulesHandle, rulesHowToRead: HOW_TO_READ_RULES, mustRead: mustRead.map((e) => e.title) }
|
|
340
372
|
: {}),
|
|
341
373
|
...(rules.rulesUnchanged ? { rulesHowToRead: HOW_TO_READ_RULES } : {}),
|
|
342
374
|
inProgress: scopedTickets.map((t) => ({
|
|
@@ -350,6 +382,7 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
350
382
|
session: {
|
|
351
383
|
ref: instanceToken, id: registration?.sessionId ?? null, scope: registration?.scope ?? null,
|
|
352
384
|
reconnects: registration?.reconnects ?? 0, reconnectedAt: registration?.lastReconnectAt ?? null,
|
|
385
|
+
...(registration?.wake ? { wake: registration.wake } : {}), scopeExpired: registration?.scopeExpired ?? null,
|
|
353
386
|
},
|
|
354
387
|
gitHealth: {
|
|
355
388
|
unhealthy: gitHealthWithConnections
|
|
@@ -358,12 +391,22 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
358
391
|
healthyCount: gitHealthWithConnections.reduce((n, p) => n + p.connections.filter((c) => c.healthy).length, 0),
|
|
359
392
|
},
|
|
360
393
|
...(knowledge ? { knowledge: knowledgeSummary(knowledge, knowledgeBlock?.handle) } : {}),
|
|
394
|
+
...(projectFacts ? {
|
|
395
|
+
projectFacts: {
|
|
396
|
+
source: projectFacts.source,
|
|
397
|
+
projects: projectFacts.projects.map((p) => ({ projectKey: p.projectKey, total: p.total, shown: Array.isArray(p.facts) ? p.facts.length : 0 })),
|
|
398
|
+
workspace: { total: projectFacts.workspace?.total ?? 0, shown: Array.isArray(projectFacts.workspace?.facts) ? projectFacts.workspace.facts.length : 0 },
|
|
399
|
+
unknown: projectFacts.unknown,
|
|
400
|
+
...(factsBlock?.handle ? { handle: factsBlock.handle } : {}),
|
|
401
|
+
},
|
|
402
|
+
} : {}),
|
|
361
403
|
...(owners ? { responsibilities: { scoped: owners.scoped, total: owners.total } } : {}),
|
|
362
404
|
...(pendingMessages.length > 0 ? { pendingMessages } : {}),
|
|
363
405
|
...(bundle ? { ticketBundle: bundle.structured } : {}),
|
|
364
406
|
},
|
|
365
407
|
};
|
|
366
|
-
|
|
408
|
+
if (context && deliverFull && rules.rulesHash)
|
|
409
|
+
delivered = { context, hash: rules.rulesHash };
|
|
367
410
|
return result;
|
|
368
411
|
};
|
|
369
412
|
}
|