@9thprotocol/agent-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +10 -0
- package/dist/compaction.d.ts +69 -0
- package/dist/compaction.js +174 -0
- package/dist/delegate.d.ts +84 -0
- package/dist/delegate.js +135 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +18 -0
- package/dist/mcp.d.ts +13 -0
- package/dist/mcp.js +78 -0
- package/dist/memory.d.ts +7 -0
- package/dist/memory.js +33 -0
- package/dist/model/openrouter.d.ts +61 -0
- package/dist/model/openrouter.js +135 -0
- package/dist/model/router.d.ts +60 -0
- package/dist/model/router.js +171 -0
- package/dist/permissions.d.ts +5 -0
- package/dist/permissions.js +16 -0
- package/dist/prompt.d.ts +5 -0
- package/dist/prompt.js +31 -0
- package/dist/scripts/compaction-live.d.ts +1 -0
- package/dist/scripts/compaction-live.js +80 -0
- package/dist/scripts/compaction-smoke.d.ts +1 -0
- package/dist/scripts/compaction-smoke.js +143 -0
- package/dist/scripts/delegation-live.d.ts +1 -0
- package/dist/scripts/delegation-live.js +122 -0
- package/dist/scripts/delegation-smoke.d.ts +1 -0
- package/dist/scripts/delegation-smoke.js +140 -0
- package/dist/scripts/router-live.d.ts +1 -0
- package/dist/scripts/router-live.js +73 -0
- package/dist/scripts/router-smoke.d.ts +1 -0
- package/dist/scripts/router-smoke.js +58 -0
- package/dist/scripts/smoke.d.ts +1 -0
- package/dist/scripts/smoke.js +52 -0
- package/dist/session.d.ts +73 -0
- package/dist/session.js +574 -0
- package/dist/skills.d.ts +14 -0
- package/dist/skills.js +56 -0
- package/dist/tools/bash.d.ts +2 -0
- package/dist/tools/bash.js +38 -0
- package/dist/tools/fs-tools.d.ts +5 -0
- package/dist/tools/fs-tools.js +115 -0
- package/dist/tools/registry.d.ts +5 -0
- package/dist/tools/registry.js +12 -0
- package/dist/tools/search-tools.d.ts +3 -0
- package/dist/tools/search-tools.js +84 -0
- package/dist/tools/types.d.ts +27 -0
- package/dist/tools/types.js +15 -0
- package/dist/types.d.ts +130 -0
- package/dist/types.js +2 -0
- package/dist/vault.d.ts +13 -0
- package/dist/vault.js +81 -0
- package/package.json +29 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
9th Protocol — Proprietary License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 9th Protocol. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This software and its source code are the property of 9th Protocol.
|
|
6
|
+
It is licensed, not sold. You may install and use the unmodified
|
|
7
|
+
package for its intended purpose. You may not copy, modify, merge,
|
|
8
|
+
publish, distribute, sublicense, decompile, or reverse engineer it,
|
|
9
|
+
or use it to build a competing agent product, without a separate
|
|
10
|
+
written license from 9th Protocol.
|
|
11
|
+
|
|
12
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
13
|
+
EXPRESS OR IMPLIED. IN NO EVENT SHALL 9TH PROTOCOL BE LIABLE FOR
|
|
14
|
+
ANY CLAIM, DAMAGES OR OTHER LIABILITY ARISING FROM THE SOFTWARE.
|
|
15
|
+
|
|
16
|
+
Contact: licensing@9thprotocol.com
|
package/README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# @9thprotocol/agent-core
|
|
2
|
+
|
|
3
|
+
The 9th Protocol agent engine: agent loop, tools, permissions, sub-agents,
|
|
4
|
+
context management, model routing. Proprietary — see [LICENSE](./LICENSE).
|
|
5
|
+
|
|
6
|
+
This is the engine behind [`9p`](https://www.npmjs.com/package/@9thprotocol/cli),
|
|
7
|
+
the 9th Protocol terminal coding agent. It is published closed-source; use the
|
|
8
|
+
CLI directly unless you have a written integration agreement.
|
|
9
|
+
|
|
10
|
+
Node 22+ required.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context compaction. Keep a long session inside the model's context window.
|
|
3
|
+
*
|
|
4
|
+
* Without this, a session that runs long enough eventually sends more tokens
|
|
5
|
+
* than the model accepts and every subsequent turn fails. Compaction summarises
|
|
6
|
+
* the older part of the conversation into one message and keeps recent turns
|
|
7
|
+
* verbatim, so work continues instead of dying.
|
|
8
|
+
*
|
|
9
|
+
* The subtle constraint is **tool-call pairing**: an assistant message carrying
|
|
10
|
+
* `tool_calls` must be followed by a `tool` message for every one of those ids.
|
|
11
|
+
* Cutting the history at an arbitrary index orphans tool results (or leaves
|
|
12
|
+
* dangling calls) and the provider rejects the request outright. Everything here
|
|
13
|
+
* cuts only at boundaries where the remainder is self-contained.
|
|
14
|
+
*/
|
|
15
|
+
import type { ChatMessage } from "./model/openrouter.js";
|
|
16
|
+
/** Fallback when the model's real context length is unknown. */
|
|
17
|
+
export declare const DEFAULT_CONTEXT_TOKENS = 200000;
|
|
18
|
+
/** Compact once estimated usage passes this share of the window. */
|
|
19
|
+
export declare const COMPACT_THRESHOLD = 0.7;
|
|
20
|
+
/**
|
|
21
|
+
* Rough token estimate: ~4 chars per token.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately not a real tokenizer. That would mean shipping model-specific
|
|
24
|
+
* vocabularies for 18 models. This drives a *threshold*, and erring high just
|
|
25
|
+
* compacts slightly early, which is harmless.
|
|
26
|
+
*/
|
|
27
|
+
export declare function estimateTokens(messages: ChatMessage[]): number;
|
|
28
|
+
/**
|
|
29
|
+
* Pick the latest safe cut point that still leaves a useful amount of recent
|
|
30
|
+
* history verbatim.
|
|
31
|
+
*
|
|
32
|
+
* Walks backwards accumulating tokens until the kept tail fills its share of
|
|
33
|
+
* the window, then snaps *backwards* to the nearest safe boundary. Snapping
|
|
34
|
+
* backwards (keeping more) rather than forwards guarantees we never cut into an
|
|
35
|
+
* exchange to hit a token target.
|
|
36
|
+
*
|
|
37
|
+
* Returns 0 when no safe boundary exists. Nothing is compacted rather than
|
|
38
|
+
* risking a malformed request.
|
|
39
|
+
*/
|
|
40
|
+
export declare function findCutpoint(messages: ChatMessage[], contextTokens?: number): number;
|
|
41
|
+
/** Render a slice as plain text for the summariser. */
|
|
42
|
+
export declare function renderTranscript(messages: ChatMessage[]): string;
|
|
43
|
+
/**
|
|
44
|
+
* The instruction given to the summariser model.
|
|
45
|
+
*
|
|
46
|
+
* BYOK only. In platform mode the API assembles this from `context.mode`
|
|
47
|
+
* ("compaction"), the same rule as every other prompt (PLAN.md §1.3), and the
|
|
48
|
+
* client sends the bare transcript. Keep the two in step: the copy the server
|
|
49
|
+
* uses lives in `api/src/prompts/system.ts`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function summaryPrompt(transcript: string): string;
|
|
52
|
+
export interface CompactionPlan {
|
|
53
|
+
/** Messages to summarise (already excludes the system prefix). */
|
|
54
|
+
toSummarise: ChatMessage[];
|
|
55
|
+
/** Messages kept verbatim after the summary. */
|
|
56
|
+
kept: ChatMessage[];
|
|
57
|
+
/** Leading system messages, preserved as-is. */
|
|
58
|
+
systemPrefix: ChatMessage[];
|
|
59
|
+
beforeTokens: number;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Decide what to compact. Returns null when compaction isn't possible or worth
|
|
63
|
+
* it. The caller should carry on unchanged.
|
|
64
|
+
*/
|
|
65
|
+
export declare function planCompaction(messages: ChatMessage[], contextTokens?: number): CompactionPlan | null;
|
|
66
|
+
/** Rebuild the message list around a completed summary. */
|
|
67
|
+
export declare function applyCompaction(plan: CompactionPlan, summary: string): ChatMessage[];
|
|
68
|
+
/** Should we compact before the next request? */
|
|
69
|
+
export declare function shouldCompact(messages: ChatMessage[], contextTokens: number): boolean;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/** Fallback when the model's real context length is unknown. */
|
|
2
|
+
export const DEFAULT_CONTEXT_TOKENS = 200_000;
|
|
3
|
+
/** Compact once estimated usage passes this share of the window. */
|
|
4
|
+
export const COMPACT_THRESHOLD = 0.7;
|
|
5
|
+
/**
|
|
6
|
+
* Share of the window reserved for verbatim recent history. The rest is
|
|
7
|
+
* summarised. Token-based rather than a message count: a session can be six
|
|
8
|
+
* messages long and still overflow (reading a few large files does it), and a
|
|
9
|
+
* fixed "keep the last N messages" rule can never compact that case.
|
|
10
|
+
*/
|
|
11
|
+
const KEEP_RECENT_SHARE = 0.3;
|
|
12
|
+
/** Always keep at least this many messages verbatim, however large they are. */
|
|
13
|
+
const KEEP_RECENT_MIN = 4;
|
|
14
|
+
/** Never compact below this many messages; there's nothing worth summarising. */
|
|
15
|
+
const MIN_TO_COMPACT = 4;
|
|
16
|
+
/**
|
|
17
|
+
* Rough token estimate: ~4 chars per token.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately not a real tokenizer. That would mean shipping model-specific
|
|
20
|
+
* vocabularies for 18 models. This drives a *threshold*, and erring high just
|
|
21
|
+
* compacts slightly early, which is harmless.
|
|
22
|
+
*/
|
|
23
|
+
export function estimateTokens(messages) {
|
|
24
|
+
let chars = 0;
|
|
25
|
+
for (const m of messages) {
|
|
26
|
+
chars += typeof m.content === "string" ? m.content.length : 0;
|
|
27
|
+
if (m.role === "assistant" && m.tool_calls) {
|
|
28
|
+
for (const tc of m.tool_calls) {
|
|
29
|
+
chars += tc.function.name.length + tc.function.arguments.length;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return Math.ceil(chars / 4);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* True when `messages[0..index)` can be cut away without orphaning anything -
|
|
37
|
+
* i.e. `messages[index]` starts a fresh exchange rather than continuing one.
|
|
38
|
+
*
|
|
39
|
+
* A `tool` message always belongs to the assistant turn before it, so a cut
|
|
40
|
+
* landing on one would strand it. An assistant message may itself be the reply
|
|
41
|
+
* being answered by following tool results, so only `user` messages (and the
|
|
42
|
+
* very end) are safe boundaries.
|
|
43
|
+
*/
|
|
44
|
+
function isSafeCut(messages, index) {
|
|
45
|
+
if (index <= 0 || index >= messages.length)
|
|
46
|
+
return false;
|
|
47
|
+
return messages[index].role === "user";
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Pick the latest safe cut point that still leaves a useful amount of recent
|
|
51
|
+
* history verbatim.
|
|
52
|
+
*
|
|
53
|
+
* Walks backwards accumulating tokens until the kept tail fills its share of
|
|
54
|
+
* the window, then snaps *backwards* to the nearest safe boundary. Snapping
|
|
55
|
+
* backwards (keeping more) rather than forwards guarantees we never cut into an
|
|
56
|
+
* exchange to hit a token target.
|
|
57
|
+
*
|
|
58
|
+
* Returns 0 when no safe boundary exists. Nothing is compacted rather than
|
|
59
|
+
* risking a malformed request.
|
|
60
|
+
*/
|
|
61
|
+
export function findCutpoint(messages, contextTokens = DEFAULT_CONTEXT_TOKENS) {
|
|
62
|
+
const keepBudget = Math.max(1, contextTokens * KEEP_RECENT_SHARE);
|
|
63
|
+
let kept = 0;
|
|
64
|
+
let boundary = messages.length;
|
|
65
|
+
for (let i = messages.length - 1; i > 0; i--) {
|
|
66
|
+
kept += estimateTokens([messages[i]]);
|
|
67
|
+
const keptEnough = messages.length - i >= KEEP_RECENT_MIN;
|
|
68
|
+
if (kept >= keepBudget && keptEnough) {
|
|
69
|
+
boundary = i;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
boundary = i;
|
|
73
|
+
}
|
|
74
|
+
// Snap backwards to a boundary that leaves the tail self-contained.
|
|
75
|
+
for (let i = boundary; i > 0; i--) {
|
|
76
|
+
if (isSafeCut(messages, i))
|
|
77
|
+
return i;
|
|
78
|
+
}
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
/** Leading system messages are configuration, never conversation. Never cut. */
|
|
82
|
+
function systemPrefixLength(messages) {
|
|
83
|
+
let i = 0;
|
|
84
|
+
while (i < messages.length && messages[i].role === "system")
|
|
85
|
+
i++;
|
|
86
|
+
return i;
|
|
87
|
+
}
|
|
88
|
+
/** Render a slice as plain text for the summariser. */
|
|
89
|
+
export function renderTranscript(messages) {
|
|
90
|
+
const lines = [];
|
|
91
|
+
for (const m of messages) {
|
|
92
|
+
if (m.role === "system")
|
|
93
|
+
continue;
|
|
94
|
+
if (m.role === "user") {
|
|
95
|
+
lines.push(`USER: ${m.content}`);
|
|
96
|
+
}
|
|
97
|
+
else if (m.role === "assistant") {
|
|
98
|
+
if (m.content)
|
|
99
|
+
lines.push(`ASSISTANT: ${m.content}`);
|
|
100
|
+
for (const tc of m.tool_calls ?? []) {
|
|
101
|
+
lines.push(`ASSISTANT CALLED ${tc.function.name}(${truncate(tc.function.arguments, 400)})`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
lines.push(`TOOL RESULT: ${truncate(m.content, 800)}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
|
110
|
+
function truncate(s, max) {
|
|
111
|
+
return s.length > max ? `${s.slice(0, max)}…[truncated]` : s;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The instruction given to the summariser model.
|
|
115
|
+
*
|
|
116
|
+
* BYOK only. In platform mode the API assembles this from `context.mode`
|
|
117
|
+
* ("compaction"), the same rule as every other prompt (PLAN.md §1.3), and the
|
|
118
|
+
* client sends the bare transcript. Keep the two in step: the copy the server
|
|
119
|
+
* uses lives in `api/src/prompts/system.ts`.
|
|
120
|
+
*/
|
|
121
|
+
export function summaryPrompt(transcript) {
|
|
122
|
+
return `Summarise this coding-session transcript so another agent can pick up the work with no other context.
|
|
123
|
+
|
|
124
|
+
Preserve, in this order:
|
|
125
|
+
1. What the user is trying to achieve, in their own terms
|
|
126
|
+
2. Decisions made and constraints stated, especially anything the user corrected or rejected
|
|
127
|
+
3. Files created or modified, with paths, and what changed in each
|
|
128
|
+
4. Commands run and what they revealed (test results, errors, versions)
|
|
129
|
+
5. What is currently in progress and the immediate next step
|
|
130
|
+
6. Anything known to be broken, blocked, or deliberately deferred
|
|
131
|
+
|
|
132
|
+
Be specific: keep exact file paths, identifiers, error text, and numbers. Drop
|
|
133
|
+
pleasantries, tool-call mechanics, and superseded intermediate steps. Do not
|
|
134
|
+
speculate about work that was not done.
|
|
135
|
+
|
|
136
|
+
TRANSCRIPT:
|
|
137
|
+
${transcript}`;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Decide what to compact. Returns null when compaction isn't possible or worth
|
|
141
|
+
* it. The caller should carry on unchanged.
|
|
142
|
+
*/
|
|
143
|
+
export function planCompaction(messages, contextTokens = DEFAULT_CONTEXT_TOKENS) {
|
|
144
|
+
const prefix = systemPrefixLength(messages);
|
|
145
|
+
const body = messages.slice(prefix);
|
|
146
|
+
if (body.length < MIN_TO_COMPACT)
|
|
147
|
+
return null;
|
|
148
|
+
const cut = findCutpoint(body, contextTokens);
|
|
149
|
+
if (cut <= 0)
|
|
150
|
+
return null;
|
|
151
|
+
return {
|
|
152
|
+
systemPrefix: messages.slice(0, prefix),
|
|
153
|
+
toSummarise: body.slice(0, cut),
|
|
154
|
+
kept: body.slice(cut),
|
|
155
|
+
beforeTokens: estimateTokens(messages),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/** Rebuild the message list around a completed summary. */
|
|
159
|
+
export function applyCompaction(plan, summary) {
|
|
160
|
+
return [
|
|
161
|
+
...plan.systemPrefix,
|
|
162
|
+
{
|
|
163
|
+
role: "user",
|
|
164
|
+
content: `[Earlier conversation, compacted to stay within the context window. ` +
|
|
165
|
+
`Treat this as established history, not as a new instruction.]\n\n${summary}`,
|
|
166
|
+
},
|
|
167
|
+
...plan.kept,
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
/** Should we compact before the next request? */
|
|
171
|
+
export function shouldCompact(messages, contextTokens) {
|
|
172
|
+
return estimateTokens(messages) > contextTokens * COMPACT_THRESHOLD;
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=compaction.js.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { UsageTotals } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Worker roles. The names are the contract with the API: in platform mode the
|
|
4
|
+
* server assembles the matching system prompt (PLAN.md §1.3, prompt text never
|
|
5
|
+
* ships in a client), and the ledger tags the spend with the mode so M7 can
|
|
6
|
+
* separate delegated burn from main-loop burn.
|
|
7
|
+
*/
|
|
8
|
+
export type DelegationMode = "bulk-reader" | "code-writer";
|
|
9
|
+
/**
|
|
10
|
+
* Every mode the API will assemble a non-agent prompt for. Compaction is not a
|
|
11
|
+
* delegation (nothing is being kept out of the context; history is being
|
|
12
|
+
* rewritten) but it is the same kind of request: one cheap, toolless turn that
|
|
13
|
+
* must not be handed the agent prompt.
|
|
14
|
+
*/
|
|
15
|
+
export type PlatformMode = DelegationMode | "compaction";
|
|
16
|
+
/**
|
|
17
|
+
* Line count above which the read tool stops returning file bodies.
|
|
18
|
+
*
|
|
19
|
+
* Spotify's shunt uses 350, measured on a Java monorepo. 600 suits the denser
|
|
20
|
+
* TypeScript this agent is mostly pointed at: it still catches the files that
|
|
21
|
+
* dominate a context window while leaving ordinary modules directly readable,
|
|
22
|
+
* which matters because a blocked read costs a round trip before any edit.
|
|
23
|
+
*/
|
|
24
|
+
export declare const DELEGATION_MIN_LINES = 600;
|
|
25
|
+
/**
|
|
26
|
+
* Ceiling on one delegated request. Well inside an economy model's window, and
|
|
27
|
+
* a corpus this large is a sign the caller should have grepped first.
|
|
28
|
+
*/
|
|
29
|
+
export declare const MAX_CORPUS_CHARS = 600000;
|
|
30
|
+
/**
|
|
31
|
+
* BYOK fallback prompts. In platform mode these are never sent: the API strips
|
|
32
|
+
* client system messages and assembles its own from the mode name. They exist
|
|
33
|
+
* so a user on their own OpenRouter key gets the same worker behaviour.
|
|
34
|
+
*
|
|
35
|
+
* Both prompts are written to suppress prose. The instruction that saves the
|
|
36
|
+
* most is "no markdown fences" on the writer: without it the worker wraps its
|
|
37
|
+
* output in formatting and commentary that the caller then has to read and
|
|
38
|
+
* strip, which puts the payload straight back into the context this exists to
|
|
39
|
+
* protect.
|
|
40
|
+
*/
|
|
41
|
+
export declare const WORKER_PROMPTS: Record<DelegationMode, string>;
|
|
42
|
+
export interface DelegationResult {
|
|
43
|
+
text: string;
|
|
44
|
+
usage: UsageTotals;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* One ephemeral worker turn. Toolless by construction: a worker that could call
|
|
48
|
+
* tools would be a sub-agent, with a sub-agent's cost.
|
|
49
|
+
*/
|
|
50
|
+
export declare function delegate(opts: {
|
|
51
|
+
apiKey: string;
|
|
52
|
+
model: string;
|
|
53
|
+
mode: DelegationMode;
|
|
54
|
+
message: string;
|
|
55
|
+
cwd: string;
|
|
56
|
+
sessionId: string;
|
|
57
|
+
platform?: {
|
|
58
|
+
baseUrl: string;
|
|
59
|
+
};
|
|
60
|
+
signal?: AbortSignal;
|
|
61
|
+
}): Promise<DelegationResult>;
|
|
62
|
+
/**
|
|
63
|
+
* Wrap each file in a tagged block so the worker sees clear boundaries, and
|
|
64
|
+
* number the lines so it can cite positions the caller can then read directly.
|
|
65
|
+
* Those citations are what make a delegated read usable as the setup for a
|
|
66
|
+
* targeted edit rather than a dead end.
|
|
67
|
+
*/
|
|
68
|
+
export declare function buildReadCorpus(files: Array<{
|
|
69
|
+
path: string;
|
|
70
|
+
content: string;
|
|
71
|
+
}>, question: string): string;
|
|
72
|
+
/** Spec first, then the references whose conventions the output must match. */
|
|
73
|
+
export declare function buildWriteCorpus(spec: string, references: Array<{
|
|
74
|
+
path: string;
|
|
75
|
+
content: string;
|
|
76
|
+
}>): string;
|
|
77
|
+
/**
|
|
78
|
+
* Workers wrap output in fences despite being told not to, often enough that
|
|
79
|
+
* stripping them is cheaper than a retry. Only whole fence lines go: a fence
|
|
80
|
+
* indented inside a string literal or a docstring is content.
|
|
81
|
+
*/
|
|
82
|
+
export declare function stripFences(s: string): string;
|
|
83
|
+
/** Rough parity with `estimateTokens`, for reporting what stayed out of context. */
|
|
84
|
+
export declare function estimateCorpusTokens(chars: number): number;
|
package/dist/delegate.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delegation: I/O-heavy work runs on an economy worker model, in its own
|
|
3
|
+
* request, and only the answer comes back into the session's context.
|
|
4
|
+
*
|
|
5
|
+
* The premise is that most of what the agent does is not reasoning. Reading a
|
|
6
|
+
* 4,000-line file to answer one question about it, or writing the twentieth
|
|
7
|
+
* test that copies the nineteen next to it, is transport. Paying frontier
|
|
8
|
+
* rates for it is bad enough once; the file is then resent on every following
|
|
9
|
+
* turn, so an 8k-token read is an 8k-token tax for the rest of the session.
|
|
10
|
+
*
|
|
11
|
+
* Two things make this different from spawning an explore sub-agent:
|
|
12
|
+
*
|
|
13
|
+
* 1. A sub-agent holds a conversation and returns prose. A delegation is one
|
|
14
|
+
* shot: one request, structured bullets or a file on disk, nothing kept.
|
|
15
|
+
* 2. The corpus never enters the parent's history at all, so asking a second
|
|
16
|
+
* question about the same files costs the parent nothing.
|
|
17
|
+
*
|
|
18
|
+
* Enforcement lives in the read tool (see `tools/fs-tools.ts`), not in the
|
|
19
|
+
* system prompt. Advice about which tool to use is a suggestion the model is
|
|
20
|
+
* free to skip on a busy turn; a tool that refuses to return the body is not.
|
|
21
|
+
*
|
|
22
|
+
* Worker burn is billed like any other request and rolls into the session's
|
|
23
|
+
* usage, so a delegation is cheaper, never free. Below `DELEGATION_MIN_LINES`
|
|
24
|
+
* the round trip costs more than the tokens it saves.
|
|
25
|
+
*/
|
|
26
|
+
import { streamChat } from "./model/openrouter.js";
|
|
27
|
+
/**
|
|
28
|
+
* Line count above which the read tool stops returning file bodies.
|
|
29
|
+
*
|
|
30
|
+
* Spotify's shunt uses 350, measured on a Java monorepo. 600 suits the denser
|
|
31
|
+
* TypeScript this agent is mostly pointed at: it still catches the files that
|
|
32
|
+
* dominate a context window while leaving ordinary modules directly readable,
|
|
33
|
+
* which matters because a blocked read costs a round trip before any edit.
|
|
34
|
+
*/
|
|
35
|
+
export const DELEGATION_MIN_LINES = 600;
|
|
36
|
+
/**
|
|
37
|
+
* Ceiling on one delegated request. Well inside an economy model's window, and
|
|
38
|
+
* a corpus this large is a sign the caller should have grepped first.
|
|
39
|
+
*/
|
|
40
|
+
export const MAX_CORPUS_CHARS = 600_000;
|
|
41
|
+
/**
|
|
42
|
+
* BYOK fallback prompts. In platform mode these are never sent: the API strips
|
|
43
|
+
* client system messages and assembles its own from the mode name. They exist
|
|
44
|
+
* so a user on their own OpenRouter key gets the same worker behaviour.
|
|
45
|
+
*
|
|
46
|
+
* Both prompts are written to suppress prose. The instruction that saves the
|
|
47
|
+
* most is "no markdown fences" on the writer: without it the worker wraps its
|
|
48
|
+
* output in formatting and commentary that the caller then has to read and
|
|
49
|
+
* strip, which puts the payload straight back into the context this exists to
|
|
50
|
+
* protect.
|
|
51
|
+
*/
|
|
52
|
+
export const WORKER_PROMPTS = {
|
|
53
|
+
"bulk-reader": "You are a precise code analyst. Read the provided files and answer the question concisely. Output structured bullets only. No greetings, no prose, no preamble, no closing summary. Lead every bullet with the exact name, type, or line number. Use nested bullets for detail. Cite line numbers as path:line, taken from the numbers in the input. Skip anything the caller did not ask for. If the files do not answer the question, say so in one bullet rather than guessing.",
|
|
54
|
+
"code-writer": "You generate code files from a spec and reference files. Match the reference's patterns, conventions, naming, imports, and style exactly. Output only the code: no explanation, no commentary, no markdown fences. If the spec is ambiguous, choose whatever matches the reference.",
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* One ephemeral worker turn. Toolless by construction: a worker that could call
|
|
58
|
+
* tools would be a sub-agent, with a sub-agent's cost.
|
|
59
|
+
*/
|
|
60
|
+
export async function delegate(opts) {
|
|
61
|
+
if (opts.message.length > MAX_CORPUS_CHARS) {
|
|
62
|
+
throw new Error(`request is ${opts.message.length} chars, over the ${MAX_CORPUS_CHARS} limit. Send fewer or smaller files.`);
|
|
63
|
+
}
|
|
64
|
+
// Platform mode: the server writes the system prompt from `context.mode`.
|
|
65
|
+
// BYOK: there is no server, so carry the prompt locally.
|
|
66
|
+
const messages = opts.platform
|
|
67
|
+
? [{ role: "user", content: opts.message }]
|
|
68
|
+
: [
|
|
69
|
+
{ role: "system", content: WORKER_PROMPTS[opts.mode] },
|
|
70
|
+
{ role: "user", content: opts.message },
|
|
71
|
+
];
|
|
72
|
+
let text = "";
|
|
73
|
+
let usage = { inputTokens: 0, cachedTokens: 0, outputTokens: 0, requests: 0 };
|
|
74
|
+
for await (const ev of streamChat({
|
|
75
|
+
apiKey: opts.apiKey,
|
|
76
|
+
model: opts.model,
|
|
77
|
+
messages,
|
|
78
|
+
tools: [],
|
|
79
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
80
|
+
...(opts.platform
|
|
81
|
+
? {
|
|
82
|
+
baseUrl: opts.platform.baseUrl,
|
|
83
|
+
extraBody: {
|
|
84
|
+
context: { cwd: opts.cwd, sessionId: opts.sessionId, mode: opts.mode },
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
: {}),
|
|
88
|
+
})) {
|
|
89
|
+
if (ev.type === "done") {
|
|
90
|
+
text = ev.result.message.content ?? "";
|
|
91
|
+
usage = ev.result.usage;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!text.trim())
|
|
95
|
+
throw new Error(`the ${opts.mode} worker returned nothing`);
|
|
96
|
+
return { text: text.trim(), usage };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Wrap each file in a tagged block so the worker sees clear boundaries, and
|
|
100
|
+
* number the lines so it can cite positions the caller can then read directly.
|
|
101
|
+
* Those citations are what make a delegated read usable as the setup for a
|
|
102
|
+
* targeted edit rather than a dead end.
|
|
103
|
+
*/
|
|
104
|
+
export function buildReadCorpus(files, question) {
|
|
105
|
+
const blocks = files.map((f) => {
|
|
106
|
+
const numbered = f.content
|
|
107
|
+
.split("\n")
|
|
108
|
+
.map((line, i) => `${i + 1}\t${line}`)
|
|
109
|
+
.join("\n");
|
|
110
|
+
return `<file path="${f.path}">\n${numbered}\n</file>`;
|
|
111
|
+
});
|
|
112
|
+
return `${blocks.join("\n\n")}\n\nQuestion: ${question}\n`;
|
|
113
|
+
}
|
|
114
|
+
/** Spec first, then the references whose conventions the output must match. */
|
|
115
|
+
export function buildWriteCorpus(spec, references) {
|
|
116
|
+
const blocks = references.map((r) => `<reference path="${r.path}">\n${r.content}\n</reference>`);
|
|
117
|
+
return `Spec: ${spec}\n\n${blocks.join("\n\n")}\n`;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Workers wrap output in fences despite being told not to, often enough that
|
|
121
|
+
* stripping them is cheaper than a retry. Only whole fence lines go: a fence
|
|
122
|
+
* indented inside a string literal or a docstring is content.
|
|
123
|
+
*/
|
|
124
|
+
export function stripFences(s) {
|
|
125
|
+
return s
|
|
126
|
+
.split("\n")
|
|
127
|
+
.filter((line) => !/^```/.test(line))
|
|
128
|
+
.join("\n")
|
|
129
|
+
.trim();
|
|
130
|
+
}
|
|
131
|
+
/** Rough parity with `estimateTokens`, for reporting what stayed out of context. */
|
|
132
|
+
export function estimateCorpusTokens(chars) {
|
|
133
|
+
return Math.ceil(chars / 4);
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=delegate.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @9thprotocol/agent-core, the agent engine.
|
|
3
|
+
* Loop + core tools + permission modes + streaming (M1); platform transport (M2);
|
|
4
|
+
* sub-agents, ask_user, 9P.md memory, skills, MCP client (M3).
|
|
5
|
+
* Next: Auto router, context compaction, Ink TUI/VS Code hosts (M5).
|
|
6
|
+
*/
|
|
7
|
+
export declare const AGENT_CORE_VERSION = "0.1.0";
|
|
8
|
+
export { AgentSession } from "./session.js";
|
|
9
|
+
export { CORE_TOOLS } from "./tools/registry.js";
|
|
10
|
+
export { McpManager } from "./mcp.js";
|
|
11
|
+
export { loadSkills, skillMessage, type Skill } from "./skills.js";
|
|
12
|
+
export { loadMemory } from "./memory.js";
|
|
13
|
+
export { loadProjectConfig, resolveVault, saveProjectConfig, scaffoldVault, vaultProtocol, type ProjectConfig, } from "./vault.js";
|
|
14
|
+
export { ApiError } from "./model/openrouter.js";
|
|
15
|
+
export { DELEGATION_MIN_LINES, MAX_CORPUS_CHARS, WORKER_PROMPTS, buildReadCorpus, buildWriteCorpus, delegate, estimateCorpusTokens, stripFences, type DelegationMode, type DelegationResult, type PlatformMode, } from "./delegate.js";
|
|
16
|
+
export { COMPACT_THRESHOLD, DEFAULT_CONTEXT_TOKENS, estimateTokens, planCompaction, shouldCompact, } from "./compaction.js";
|
|
17
|
+
export { AUTO_MODEL, classify, route, workerModel, type RouteDecision, type RouteInput, type RouterBias, type RouterCandidate, type TaskComplexity, } from "./model/router.js";
|
|
18
|
+
export type { AgentEvent, AskUserHandler, DelegationTotals, AskUserQuestion, PermissionDecider, PermissionMode, PermissionRequest, SessionOptions, ToolCallRequest, UsageTotals, } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @9thprotocol/agent-core, the agent engine.
|
|
3
|
+
* Loop + core tools + permission modes + streaming (M1); platform transport (M2);
|
|
4
|
+
* sub-agents, ask_user, 9P.md memory, skills, MCP client (M3).
|
|
5
|
+
* Next: Auto router, context compaction, Ink TUI/VS Code hosts (M5).
|
|
6
|
+
*/
|
|
7
|
+
export const AGENT_CORE_VERSION = "0.1.0";
|
|
8
|
+
export { AgentSession } from "./session.js";
|
|
9
|
+
export { CORE_TOOLS } from "./tools/registry.js";
|
|
10
|
+
export { McpManager } from "./mcp.js";
|
|
11
|
+
export { loadSkills, skillMessage } from "./skills.js";
|
|
12
|
+
export { loadMemory } from "./memory.js";
|
|
13
|
+
export { loadProjectConfig, resolveVault, saveProjectConfig, scaffoldVault, vaultProtocol, } from "./vault.js";
|
|
14
|
+
export { ApiError } from "./model/openrouter.js";
|
|
15
|
+
export { DELEGATION_MIN_LINES, MAX_CORPUS_CHARS, WORKER_PROMPTS, buildReadCorpus, buildWriteCorpus, delegate, estimateCorpusTokens, stripFences, } from "./delegate.js";
|
|
16
|
+
export { COMPACT_THRESHOLD, DEFAULT_CONTEXT_TOKENS, estimateTokens, planCompaction, shouldCompact, } from "./compaction.js";
|
|
17
|
+
export { AUTO_MODEL, classify, route, workerModel, } from "./model/router.js";
|
|
18
|
+
//# sourceMappingURL=index.js.map
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ToolDef } from "./tools/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Connects configured stdio MCP servers and exposes their tools as
|
|
4
|
+
* `mcp__<server>__<tool>` ToolDefs (kind "exec", permission-gated like bash).
|
|
5
|
+
*/
|
|
6
|
+
export declare class McpManager {
|
|
7
|
+
readonly tools: ToolDef[];
|
|
8
|
+
readonly servers: string[];
|
|
9
|
+
private clients;
|
|
10
|
+
static fromCwd(cwd: string): Promise<McpManager>;
|
|
11
|
+
private connect;
|
|
12
|
+
close(): Promise<void>;
|
|
13
|
+
}
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
5
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
6
|
+
/** Merge ~/.9p/mcp.json and <cwd>/.9p/mcp.json (project wins per server name). */
|
|
7
|
+
function loadConfig(cwd) {
|
|
8
|
+
const merged = {};
|
|
9
|
+
for (const file of [
|
|
10
|
+
path.join(os.homedir(), ".9p", "mcp.json"),
|
|
11
|
+
path.join(cwd, ".9p", "mcp.json"),
|
|
12
|
+
]) {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
15
|
+
Object.assign(merged, parsed.mcpServers ?? {});
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// absent or invalid, skip
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return merged;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Connects configured stdio MCP servers and exposes their tools as
|
|
25
|
+
* `mcp__<server>__<tool>` ToolDefs (kind "exec", permission-gated like bash).
|
|
26
|
+
*/
|
|
27
|
+
export class McpManager {
|
|
28
|
+
tools = [];
|
|
29
|
+
servers = [];
|
|
30
|
+
clients = [];
|
|
31
|
+
static async fromCwd(cwd) {
|
|
32
|
+
const manager = new McpManager();
|
|
33
|
+
for (const [name, cfg] of Object.entries(loadConfig(cwd))) {
|
|
34
|
+
try {
|
|
35
|
+
await manager.connect(name, cfg);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
console.error(`mcp: failed to connect "${name}": ${err instanceof Error ? err.message : err}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return manager;
|
|
42
|
+
}
|
|
43
|
+
async connect(serverName, cfg) {
|
|
44
|
+
const client = new Client({ name: "9p", version: "0.1.0" });
|
|
45
|
+
const transport = new StdioClientTransport({
|
|
46
|
+
command: cfg.command,
|
|
47
|
+
args: cfg.args ?? [],
|
|
48
|
+
env: { ...process.env, ...cfg.env },
|
|
49
|
+
});
|
|
50
|
+
await client.connect(transport);
|
|
51
|
+
this.clients.push(client);
|
|
52
|
+
this.servers.push(serverName);
|
|
53
|
+
const { tools } = await client.listTools();
|
|
54
|
+
for (const t of tools) {
|
|
55
|
+
this.tools.push({
|
|
56
|
+
name: `mcp__${serverName}__${t.name}`,
|
|
57
|
+
description: t.description ?? `${t.name} (MCP: ${serverName})`,
|
|
58
|
+
kind: "exec",
|
|
59
|
+
parameters: t.inputSchema ?? { type: "object" },
|
|
60
|
+
summarize: (input) => `mcp ${serverName}.${t.name}(${JSON.stringify(input).slice(0, 80)})`,
|
|
61
|
+
run: async (input) => {
|
|
62
|
+
const result = await client.callTool({ name: t.name, arguments: input });
|
|
63
|
+
const content = (result.content ?? []);
|
|
64
|
+
const text = content
|
|
65
|
+
.map((c) => (c.type === "text" ? (c.text ?? "") : `[${c.type}]`))
|
|
66
|
+
.join("\n");
|
|
67
|
+
if (result.isError)
|
|
68
|
+
throw new Error(text || "MCP tool error");
|
|
69
|
+
return text || "[no output]";
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async close() {
|
|
75
|
+
await Promise.allSettled(this.clients.map((c) => c.close()));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=mcp.js.map
|
package/dist/memory.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory context loaded every session: 9P.md standing instructions (global
|
|
3
|
+
* ~/.9p/9P.md, then project 9P.md, project wins by appearing last), plus the
|
|
4
|
+
* vault protocol when the project links an Obsidian-style vault. The vault's
|
|
5
|
+
* *content* is never bulk-loaded, the agent navigates it with tools.
|
|
6
|
+
*/
|
|
7
|
+
export declare function loadMemory(cwd: string): string;
|
package/dist/memory.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { resolveVault, vaultProtocol } from "./vault.js";
|
|
5
|
+
const MEMORY_CAP = 8000; // chars, memory must never dominate the context window
|
|
6
|
+
/**
|
|
7
|
+
* Memory context loaded every session: 9P.md standing instructions (global
|
|
8
|
+
* ~/.9p/9P.md, then project 9P.md, project wins by appearing last), plus the
|
|
9
|
+
* vault protocol when the project links an Obsidian-style vault. The vault's
|
|
10
|
+
* *content* is never bulk-loaded, the agent navigates it with tools.
|
|
11
|
+
*/
|
|
12
|
+
export function loadMemory(cwd) {
|
|
13
|
+
const sources = [
|
|
14
|
+
["Global memory", path.join(os.homedir(), ".9p", "9P.md")],
|
|
15
|
+
["Project memory", path.join(cwd, "9P.md")],
|
|
16
|
+
];
|
|
17
|
+
const parts = [];
|
|
18
|
+
for (const [label, file] of sources) {
|
|
19
|
+
try {
|
|
20
|
+
const text = fs.readFileSync(file, "utf8").trim();
|
|
21
|
+
if (text)
|
|
22
|
+
parts.push(`### ${label} (${file})\n${text}`);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// absent, fine
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const vault = resolveVault(cwd);
|
|
29
|
+
if (vault)
|
|
30
|
+
parts.push(vaultProtocol(vault));
|
|
31
|
+
return parts.join("\n\n").slice(0, MEMORY_CAP);
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=memory.js.map
|