@zhijiewang/openharness 2.11.0 → 2.13.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/README.md +3 -0
- package/dist/commands/index.d.ts +1 -1
- package/dist/commands/index.js +1 -1
- package/dist/commands/info.js +69 -7
- package/dist/commands/mcp-auth.d.ts +11 -0
- package/dist/commands/mcp-auth.js +57 -0
- package/dist/commands/types.d.ts +1 -1
- package/dist/components/REPL.js +10 -3
- package/dist/harness/config.d.ts +5 -0
- package/dist/harness/hooks.d.ts +35 -1
- package/dist/harness/hooks.js +204 -35
- package/dist/harness/submit-handler.js +37 -3
- package/dist/mcp/client.d.ts +5 -1
- package/dist/mcp/client.js +37 -4
- package/dist/mcp/oauth-storage.d.ts +23 -0
- package/dist/mcp/oauth-storage.js +58 -0
- package/dist/mcp/oauth.d.ts +79 -0
- package/dist/mcp/oauth.js +257 -0
- package/dist/mcp/transport.d.ts +13 -2
- package/dist/mcp/transport.js +76 -16
- package/dist/providers/fallback.js +19 -7
- package/dist/providers/index.js +18 -2
- package/dist/providers/router.d.ts +4 -0
- package/dist/providers/router.js +19 -0
- package/dist/query/index.js +33 -1
- package/dist/query/tools.js +49 -11
- package/dist/query/types.d.ts +6 -0
- package/dist/tools/AgentTool/index.js +2 -2
- package/dist/tools/ScheduleWakeupTool/index.d.ts +2 -2
- package/package.json +2 -1
package/dist/mcp/transport.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
+
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
2
3
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
4
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
4
5
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
@@ -9,7 +10,7 @@ export class RemoteAuthRequiredError extends Error {
|
|
|
9
10
|
wwwAuthenticate;
|
|
10
11
|
constructor(serverName, wwwAuthenticate) {
|
|
11
12
|
super(`MCP server '${serverName}' requires authentication. ` +
|
|
12
|
-
`Add
|
|
13
|
+
`Add 'auth: oauth' to enable the OAuth 2.1 flow, or set headers.Authorization for a static bearer token.`);
|
|
13
14
|
this.name = "RemoteAuthRequiredError";
|
|
14
15
|
this.serverName = serverName;
|
|
15
16
|
this.wwwAuthenticate = wwwAuthenticate;
|
|
@@ -41,7 +42,7 @@ export class ProtocolError extends Error {
|
|
|
41
42
|
* Construct an SDK Transport for a normalized config.
|
|
42
43
|
* Does NOT call .start() — caller (Client.connect) handles that.
|
|
43
44
|
*/
|
|
44
|
-
export async function buildTransport(cfg) {
|
|
45
|
+
export async function buildTransport(cfg, opts = {}) {
|
|
45
46
|
if (cfg.type === "stdio") {
|
|
46
47
|
return new StdioClientTransport({
|
|
47
48
|
command: cfg.command,
|
|
@@ -52,11 +53,13 @@ export async function buildTransport(cfg) {
|
|
|
52
53
|
if (cfg.type === "http") {
|
|
53
54
|
return new StreamableHTTPClientTransport(new URL(cfg.url), {
|
|
54
55
|
requestInit: cfg.headers ? { headers: cfg.headers } : undefined,
|
|
56
|
+
authProvider: opts.authProvider,
|
|
55
57
|
});
|
|
56
58
|
}
|
|
57
59
|
if (cfg.type === "sse") {
|
|
58
60
|
return new SSEClientTransport(new URL(cfg.url), {
|
|
59
61
|
requestInit: cfg.headers ? { headers: cfg.headers } : undefined,
|
|
62
|
+
authProvider: opts.authProvider,
|
|
60
63
|
});
|
|
61
64
|
}
|
|
62
65
|
throw new Error(`unknown transport type: ${cfg.type}`);
|
|
@@ -112,7 +115,6 @@ export async function connectWithFallback(cfg, doConnect) {
|
|
|
112
115
|
if (!isFallbackCandidate(err))
|
|
113
116
|
throw err;
|
|
114
117
|
// Log + retry
|
|
115
|
-
// biome-ignore lint/suspicious/noConsole: user-facing diagnostic
|
|
116
118
|
console.warn(`[mcp] ${cfg.name}: Streamable HTTP failed (${err.message}); trying legacy SSE`);
|
|
117
119
|
const sseCfg = { ...cfg, type: "sse" };
|
|
118
120
|
return await doConnect(sseCfg);
|
|
@@ -120,25 +122,87 @@ export async function connectWithFallback(cfg, doConnect) {
|
|
|
120
122
|
}
|
|
121
123
|
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
122
124
|
const CLIENT_INFO = { name: "openharness", version: pkg.version };
|
|
125
|
+
/** Duck-type check: does this provider expose awaitCallback (our OhOAuthProvider)? */
|
|
126
|
+
function hasAwaitCallback(p) {
|
|
127
|
+
return typeof p.awaitCallback === "function";
|
|
128
|
+
}
|
|
123
129
|
/**
|
|
124
130
|
* Build a connected SDK Client for a normalized config.
|
|
125
131
|
* Maps connect-time errors into OH's typed error taxonomy.
|
|
132
|
+
*
|
|
133
|
+
* When the auth provider exposes `awaitCallback()` (i.e. OhOAuthProvider), this
|
|
134
|
+
* function handles the full OAuth callback → finishAuth → reconnect loop so callers
|
|
135
|
+
* don't need to orchestrate it manually.
|
|
126
136
|
*/
|
|
127
|
-
export async function buildClient(cfg) {
|
|
128
|
-
const transport = await buildTransport(cfg);
|
|
137
|
+
export async function buildClient(cfg, opts = {}) {
|
|
138
|
+
const transport = await buildTransport(cfg, opts);
|
|
129
139
|
const client = new Client(CLIENT_INFO, { capabilities: {} });
|
|
130
140
|
const timeoutMs = cfg.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
131
|
-
|
|
141
|
+
async function tryConnect() {
|
|
142
|
+
let timer = null;
|
|
143
|
+
try {
|
|
144
|
+
await Promise.race([
|
|
145
|
+
client.connect(transport),
|
|
146
|
+
new Promise((_, reject) => {
|
|
147
|
+
timer = setTimeout(() => reject(new Error(`init timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
148
|
+
}),
|
|
149
|
+
]);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
if (timer !== null)
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
132
156
|
try {
|
|
133
|
-
await
|
|
134
|
-
client.connect(transport),
|
|
135
|
-
new Promise((_, reject) => {
|
|
136
|
-
timer = setTimeout(() => reject(new Error(`init timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
137
|
-
}),
|
|
138
|
-
]);
|
|
157
|
+
await tryConnect();
|
|
139
158
|
return client;
|
|
140
159
|
}
|
|
141
160
|
catch (err) {
|
|
161
|
+
// If the SDK requires a browser-based OAuth flow (UnauthorizedError after REDIRECT),
|
|
162
|
+
// and our provider knows how to await the callback, complete the loop here.
|
|
163
|
+
// Per the SDK design, after finishAuth we must create a fresh transport + client
|
|
164
|
+
// because the original transport is already in a "started" state.
|
|
165
|
+
if (err instanceof UnauthorizedError && opts.authProvider && hasAwaitCallback(opts.authProvider)) {
|
|
166
|
+
try {
|
|
167
|
+
const { code } = await opts.authProvider.awaitCallback();
|
|
168
|
+
await transport.finishAuth(code);
|
|
169
|
+
// Close the old transport before constructing a fresh one — the SDK's
|
|
170
|
+
// Transport is one-shot after an UnauthorizedError; leaving it open leaks
|
|
171
|
+
// the underlying TCP socket / event stream.
|
|
172
|
+
try {
|
|
173
|
+
await transport.close?.();
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// best-effort
|
|
177
|
+
}
|
|
178
|
+
// Build a fresh transport + client for the authenticated retry
|
|
179
|
+
const freshTransport = await buildTransport(cfg, opts);
|
|
180
|
+
const freshClient = new Client(CLIENT_INFO, { capabilities: {} });
|
|
181
|
+
let freshTimer = null;
|
|
182
|
+
try {
|
|
183
|
+
await Promise.race([
|
|
184
|
+
freshClient.connect(freshTransport),
|
|
185
|
+
new Promise((_, reject) => {
|
|
186
|
+
freshTimer = setTimeout(() => reject(new Error(`init timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
187
|
+
}),
|
|
188
|
+
]);
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
if (freshTimer !== null)
|
|
192
|
+
clearTimeout(freshTimer);
|
|
193
|
+
}
|
|
194
|
+
return freshClient;
|
|
195
|
+
}
|
|
196
|
+
catch (oauthErr) {
|
|
197
|
+
// Classify the retry error the same way as the primary path
|
|
198
|
+
if (oauthErr instanceof RemoteAuthRequiredError ||
|
|
199
|
+
oauthErr instanceof UnreachableError ||
|
|
200
|
+
oauthErr instanceof ProtocolError) {
|
|
201
|
+
throw oauthErr;
|
|
202
|
+
}
|
|
203
|
+
throw new ProtocolError(cfg.name, oauthErr);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
142
206
|
// Leave RemoteAuthRequiredError / UnreachableError / ProtocolError as-is
|
|
143
207
|
if (err instanceof RemoteAuthRequiredError || err instanceof UnreachableError || err instanceof ProtocolError) {
|
|
144
208
|
throw err;
|
|
@@ -151,9 +215,5 @@ export async function buildClient(cfg) {
|
|
|
151
215
|
// Otherwise protocol-shaped
|
|
152
216
|
throw new ProtocolError(cfg.name, err);
|
|
153
217
|
}
|
|
154
|
-
finally {
|
|
155
|
-
if (timer !== null)
|
|
156
|
-
clearTimeout(timer);
|
|
157
|
-
}
|
|
158
218
|
}
|
|
159
219
|
//# sourceMappingURL=transport.js.map
|
|
@@ -33,20 +33,26 @@ export function createFallbackProvider(primary, fallbacks) {
|
|
|
33
33
|
];
|
|
34
34
|
for (let i = 0; i < providers.length; i++) {
|
|
35
35
|
const p = providers[i];
|
|
36
|
+
let hasYielded = false;
|
|
36
37
|
try {
|
|
37
|
-
let _hasYielded = false;
|
|
38
38
|
for await (const event of p.provider.stream(messages, systemPrompt, tools, p.model)) {
|
|
39
|
-
|
|
39
|
+
hasYielded = true;
|
|
40
40
|
yield event;
|
|
41
41
|
}
|
|
42
|
-
|
|
42
|
+
if (i > 0) {
|
|
43
|
+
console.warn(`[provider] fell back from ${primary.name} to ${p.provider.name}`);
|
|
44
|
+
_activeFallback = p.provider.name;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
_activeFallback = null;
|
|
48
|
+
}
|
|
43
49
|
return;
|
|
44
50
|
}
|
|
45
51
|
catch (err) {
|
|
46
|
-
// Mid-stream failure
|
|
47
|
-
if (i > 0 || !isRetriableError(err))
|
|
52
|
+
// Mid-stream failure OR non-retriable OR fallback error: propagate.
|
|
53
|
+
if (i > 0 || !isRetriableError(err) || hasYielded)
|
|
48
54
|
throw err;
|
|
49
|
-
// Pre-stream failure on primary: try next provider
|
|
55
|
+
// Pre-stream retriable failure on primary only: try next provider.
|
|
50
56
|
_activeFallback = null;
|
|
51
57
|
}
|
|
52
58
|
}
|
|
@@ -63,7 +69,13 @@ export function createFallbackProvider(primary, fallbacks) {
|
|
|
63
69
|
const p = providers[i];
|
|
64
70
|
try {
|
|
65
71
|
const result = await p.provider.complete(messages, systemPrompt, tools, p.model);
|
|
66
|
-
|
|
72
|
+
if (i > 0) {
|
|
73
|
+
console.warn(`[provider] fell back from ${primary.name} to ${p.provider.name}`);
|
|
74
|
+
_activeFallback = p.provider.name;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
_activeFallback = null;
|
|
78
|
+
}
|
|
67
79
|
return result;
|
|
68
80
|
}
|
|
69
81
|
catch (err) {
|
package/dist/providers/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Provider factory — create the right provider from a model string.
|
|
3
3
|
*/
|
|
4
|
+
import { readOhConfig } from "../harness/config.js";
|
|
4
5
|
import { AnthropicProvider } from "./anthropic.js";
|
|
6
|
+
import { createFallbackProvider } from "./fallback.js";
|
|
5
7
|
import { LlamaCppProvider } from "./llamacpp.js";
|
|
6
8
|
import { OllamaProvider } from "./ollama.js";
|
|
7
9
|
import { OpenAIProvider } from "./openai.js";
|
|
@@ -29,8 +31,22 @@ export async function createProvider(modelArg, overrides) {
|
|
|
29
31
|
defaultModel: model,
|
|
30
32
|
...overrides,
|
|
31
33
|
};
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
+
const primary = createProviderInstance(providerName, config);
|
|
35
|
+
const fallbackCfgs = readOhConfig()?.fallbackProviders ?? [];
|
|
36
|
+
if (fallbackCfgs.length === 0) {
|
|
37
|
+
return { provider: primary, model };
|
|
38
|
+
}
|
|
39
|
+
const fallbacks = fallbackCfgs.map((fb) => ({
|
|
40
|
+
provider: createProviderInstance(fb.provider, {
|
|
41
|
+
name: fb.provider,
|
|
42
|
+
apiKey: fb.apiKey ?? process.env[`${fb.provider.toUpperCase()}_API_KEY`],
|
|
43
|
+
baseUrl: fb.baseUrl,
|
|
44
|
+
defaultModel: fb.model ?? model,
|
|
45
|
+
}),
|
|
46
|
+
model: fb.model,
|
|
47
|
+
}));
|
|
48
|
+
const wrapped = createFallbackProvider(primary, fallbacks);
|
|
49
|
+
return { provider: wrapped, model };
|
|
34
50
|
}
|
|
35
51
|
export { createProviderInstance, guessProviderFromModel };
|
|
36
52
|
function createProviderInstance(name, config) {
|
|
@@ -45,4 +45,8 @@ export declare class ModelRouter {
|
|
|
45
45
|
/** Get all configured tiers */
|
|
46
46
|
get tiers(): Record<ModelTier, string>;
|
|
47
47
|
}
|
|
48
|
+
/** Record the router's selection for a session. Keeps only the most recent 256 sessions. */
|
|
49
|
+
export declare function recordRouteSelection(sessionId: string, result: RouteResult): void;
|
|
50
|
+
/** Retrieve the most recent selection for a session, or undefined. */
|
|
51
|
+
export declare function getRouteSelection(sessionId: string): RouteResult | undefined;
|
|
48
52
|
//# sourceMappingURL=router.d.ts.map
|
package/dist/providers/router.js
CHANGED
|
@@ -58,4 +58,23 @@ export class ModelRouter {
|
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
+
const ROUTE_SELECTION_CAP = 256;
|
|
62
|
+
const routeSelections = new Map();
|
|
63
|
+
/** Record the router's selection for a session. Keeps only the most recent 256 sessions. */
|
|
64
|
+
export function recordRouteSelection(sessionId, result) {
|
|
65
|
+
// Map preserves insertion order. Delete-then-set moves the key to the end,
|
|
66
|
+
// so oldest is always keys().next().
|
|
67
|
+
if (routeSelections.has(sessionId))
|
|
68
|
+
routeSelections.delete(sessionId);
|
|
69
|
+
routeSelections.set(sessionId, result);
|
|
70
|
+
if (routeSelections.size > ROUTE_SELECTION_CAP) {
|
|
71
|
+
const oldest = routeSelections.keys().next().value;
|
|
72
|
+
if (oldest !== undefined)
|
|
73
|
+
routeSelections.delete(oldest);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Retrieve the most recent selection for a session, or undefined. */
|
|
77
|
+
export function getRouteSelection(sessionId) {
|
|
78
|
+
return routeSelections.get(sessionId);
|
|
79
|
+
}
|
|
61
80
|
//# sourceMappingURL=router.js.map
|
package/dist/query/index.js
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* - types.ts — shared types
|
|
9
9
|
*/
|
|
10
10
|
import { DeferredTool } from "../DeferredTool.js";
|
|
11
|
+
import { readOhConfig } from "../harness/config.js";
|
|
11
12
|
import { getContextWindow } from "../harness/cost.js";
|
|
13
|
+
import { ModelRouter } from "../providers/router.js";
|
|
12
14
|
import { StreamingToolExecutor } from "../services/StreamingToolExecutor.js";
|
|
13
15
|
import { toolToAPIFormat } from "../Tool.js";
|
|
14
16
|
import { createAssistantMessage, createToolResultMessage, createUserMessage } from "../types/message.js";
|
|
@@ -18,8 +20,27 @@ import { isNetworkError, isOverloadError, isPromptTooLongError, isRateLimitError
|
|
|
18
20
|
import { executeToolCalls } from "./tools.js";
|
|
19
21
|
export { compressMessages } from "./compress.js";
|
|
20
22
|
const DEFAULT_MAX_TURNS = 50;
|
|
23
|
+
/** Rough context-usage estimate in [0, 1]. Returns undefined when tokenization is unavailable. */
|
|
24
|
+
function estimateRouteContextUsage(messages, provider, model) {
|
|
25
|
+
const estimate = provider.estimateTokens?.bind(provider);
|
|
26
|
+
if (!estimate)
|
|
27
|
+
return undefined;
|
|
28
|
+
const info = provider.getModelInfo?.(model);
|
|
29
|
+
const window = info?.contextWindow;
|
|
30
|
+
if (!window || window <= 0)
|
|
31
|
+
return undefined;
|
|
32
|
+
let total = 0;
|
|
33
|
+
for (const m of messages) {
|
|
34
|
+
if (typeof m.content === "string")
|
|
35
|
+
total += estimate(m.content);
|
|
36
|
+
// Non-string content (tool calls etc.) is skipped — rough estimate only.
|
|
37
|
+
}
|
|
38
|
+
return Math.min(1, total / window);
|
|
39
|
+
}
|
|
21
40
|
export async function* query(userMessage, config, existingMessages = []) {
|
|
22
41
|
const maxTurns = config.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
42
|
+
const routerCfg = readOhConfig()?.modelRouter ?? {};
|
|
43
|
+
const router = new ModelRouter(routerCfg, config.model ?? "");
|
|
23
44
|
const toolContext = {
|
|
24
45
|
workingDir: config.workingDir ?? process.cwd(),
|
|
25
46
|
abortSignal: config.abortSignal,
|
|
@@ -160,7 +181,16 @@ export async function* query(userMessage, config, existingMessages = []) {
|
|
|
160
181
|
let streamError = null;
|
|
161
182
|
const streamingExecutor = new StreamingToolExecutor(config.tools, toolContext, config.permissionMode, config.askUser, config.abortSignal);
|
|
162
183
|
try {
|
|
163
|
-
|
|
184
|
+
const ctxUsage = estimateRouteContextUsage(state.messages, config.provider, config.model ?? "");
|
|
185
|
+
const selection = router.select({
|
|
186
|
+
turn: state.turn,
|
|
187
|
+
hadToolCalls: state.lastTurnHadTools ?? false,
|
|
188
|
+
toolCallCount: state.lastTurnToolCount ?? 0,
|
|
189
|
+
contextUsage: ctxUsage,
|
|
190
|
+
isFinalResponse: (state.lastTurnHadTools === false || state.lastTurnHadTools === undefined) && state.turn > 1,
|
|
191
|
+
role: config.role,
|
|
192
|
+
});
|
|
193
|
+
for await (const event of config.provider.stream(state.messages, turnPrompt, apiTools, selection.model)) {
|
|
164
194
|
if (config.abortSignal?.aborted)
|
|
165
195
|
break;
|
|
166
196
|
switch (event.type) {
|
|
@@ -283,6 +313,8 @@ export async function* query(userMessage, config, existingMessages = []) {
|
|
|
283
313
|
if (remaining.length > 0) {
|
|
284
314
|
yield* executeToolCalls(remaining, config.tools, toolContext, config.permissionMode, config.askUser, state);
|
|
285
315
|
}
|
|
316
|
+
state.lastTurnHadTools = toolCalls.length > 0;
|
|
317
|
+
state.lastTurnToolCount = toolCalls.length;
|
|
286
318
|
state.transition = "next_turn";
|
|
287
319
|
}
|
|
288
320
|
yield { type: "turn_complete", reason: "max_turns" };
|
package/dist/query/tools.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Tool execution — permission checking, batching, output capping.
|
|
3
3
|
*/
|
|
4
4
|
import { createCheckpoint, getAffectedFiles } from "../harness/checkpoints.js";
|
|
5
|
-
import { emitHook } from "../harness/hooks.js";
|
|
5
|
+
import { emitHook, emitHookWithOutcome } from "../harness/hooks.js";
|
|
6
6
|
import { findToolByName } from "../Tool.js";
|
|
7
7
|
import { createToolResultMessage } from "../types/message.js";
|
|
8
8
|
import { checkPermission } from "../types/permissions.js";
|
|
@@ -45,9 +45,28 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
|
|
|
45
45
|
if (perm.reason === "needs-approval" && askUser) {
|
|
46
46
|
const { formatToolArgs } = await import("../utils/tool-summary.js");
|
|
47
47
|
const description = formatToolArgs(tool.name, toolCall.arguments);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
// Hook: permissionRequest — fires between preToolUse and the interactive askUser prompt.
|
|
49
|
+
// Only fires when checkPermission says "needs-approval" AND askUser is provided.
|
|
50
|
+
const hookOutcome = await emitHookWithOutcome("permissionRequest", {
|
|
51
|
+
toolName: tool.name,
|
|
52
|
+
toolArgs: JSON.stringify(toolCall.arguments).slice(0, 1000),
|
|
53
|
+
toolInputJson: JSON.stringify(parsed.data).slice(0, 1000),
|
|
54
|
+
permissionMode,
|
|
55
|
+
permissionAction: "ask",
|
|
56
|
+
});
|
|
57
|
+
if (hookOutcome.permissionDecision === "allow") {
|
|
58
|
+
// Hook granted permission — skip interactive prompt and proceed to execution.
|
|
59
|
+
}
|
|
60
|
+
else if (hookOutcome.permissionDecision === "deny" || !hookOutcome.allowed) {
|
|
61
|
+
const reason = hookOutcome.reason ? `: ${hookOutcome.reason}` : "";
|
|
62
|
+
return { output: `Permission denied by hook${reason}`, isError: true };
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
// "ask" or no decision → fall through to interactive prompt
|
|
66
|
+
const allowed = await askUser(tool.name, description, tool.riskLevel);
|
|
67
|
+
if (!allowed) {
|
|
68
|
+
return { output: "Permission denied by user.", isError: true };
|
|
69
|
+
}
|
|
51
70
|
}
|
|
52
71
|
}
|
|
53
72
|
else {
|
|
@@ -79,12 +98,23 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
|
|
|
79
98
|
toolAbort.addEventListener("abort", () => reject(new Error(`Tool '${tool.name}' timed out after ${TOOL_TIMEOUT_MS / 1000}s`)));
|
|
80
99
|
}),
|
|
81
100
|
]);
|
|
82
|
-
// Hook: postToolUse
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
101
|
+
// Hook: postToolUse / postToolUseFailure (mutually exclusive — strict CC parity)
|
|
102
|
+
if (result.isError) {
|
|
103
|
+
emitHook("postToolUseFailure", {
|
|
104
|
+
toolName: tool.name,
|
|
105
|
+
toolArgs: JSON.stringify(toolCall.arguments).slice(0, 1000),
|
|
106
|
+
toolOutput: result.output.slice(0, 1000),
|
|
107
|
+
toolError: "ReportedError",
|
|
108
|
+
errorMessage: result.output.slice(0, 1000),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
emitHook("postToolUse", {
|
|
113
|
+
toolName: tool.name,
|
|
114
|
+
toolArgs: JSON.stringify(toolCall.arguments).slice(0, 1000),
|
|
115
|
+
toolOutput: result.output.slice(0, 1000),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
88
118
|
// Emit fileChanged hook for file-modifying tools
|
|
89
119
|
if (!result.isError && ["Edit", "Write", "MultiEdit"].includes(tool.name)) {
|
|
90
120
|
const filePaths = getAffectedFiles(tool.name, parsed.data);
|
|
@@ -141,7 +171,15 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
|
|
|
141
171
|
return { output, isError: result.isError };
|
|
142
172
|
}
|
|
143
173
|
catch (err) {
|
|
144
|
-
|
|
174
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
175
|
+
const errName = err instanceof Error ? err.name : "ExecutionError";
|
|
176
|
+
emitHook("postToolUseFailure", {
|
|
177
|
+
toolName: tool.name,
|
|
178
|
+
toolArgs: JSON.stringify(toolCall.arguments).slice(0, 1000),
|
|
179
|
+
errorMessage: errMsg,
|
|
180
|
+
toolError: errName,
|
|
181
|
+
});
|
|
182
|
+
return { output: `Tool error: ${errMsg}`, isError: true };
|
|
145
183
|
}
|
|
146
184
|
}
|
|
147
185
|
export async function* executeToolCalls(toolCalls, tools, context, permissionMode, askUser, state) {
|
package/dist/query/types.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export type QueryConfig = {
|
|
|
20
20
|
workingDir?: string;
|
|
21
21
|
/** Auto-commit after each file-modifying tool */
|
|
22
22
|
gitCommitPerTool?: boolean;
|
|
23
|
+
/** For sub-agent invocations: the agent role name (feeds into the model router). */
|
|
24
|
+
role?: string;
|
|
23
25
|
};
|
|
24
26
|
export type TransitionReason = "next_turn" | "retry_network" | "retry_prompt_too_long" | "retry_max_output_tokens";
|
|
25
27
|
export type QueryLoopState = {
|
|
@@ -33,5 +35,9 @@ export type QueryLoopState = {
|
|
|
33
35
|
promptTooLongRetries?: number;
|
|
34
36
|
/** Track consecutive compression failures for circuit breaker */
|
|
35
37
|
compressionFailures?: number;
|
|
38
|
+
/** Whether the previous turn made any tool calls (feeds ModelRouter) */
|
|
39
|
+
lastTurnHadTools?: boolean;
|
|
40
|
+
/** Number of tool calls in the previous turn (feeds ModelRouter) */
|
|
41
|
+
lastTurnToolCount?: number;
|
|
36
42
|
};
|
|
37
43
|
//# sourceMappingURL=types.d.ts.map
|
|
@@ -99,7 +99,7 @@ export const AgentTool = {
|
|
|
99
99
|
const runAgent = async () => {
|
|
100
100
|
let finalText = "";
|
|
101
101
|
try {
|
|
102
|
-
for await (const event of query(input.prompt, config)) {
|
|
102
|
+
for await (const event of query(input.prompt, { ...config, role: role?.id })) {
|
|
103
103
|
if (event.type === "text_delta")
|
|
104
104
|
finalText += event.content;
|
|
105
105
|
}
|
|
@@ -137,7 +137,7 @@ export const AgentTool = {
|
|
|
137
137
|
let finalText = "";
|
|
138
138
|
try {
|
|
139
139
|
try {
|
|
140
|
-
for await (const event of query(input.prompt, config)) {
|
|
140
|
+
for await (const event of query(input.prompt, { ...config, role: role?.id })) {
|
|
141
141
|
if (event.type === "text_delta") {
|
|
142
142
|
finalText += event.content;
|
|
143
143
|
}
|
|
@@ -5,12 +5,12 @@ declare const inputSchema: z.ZodObject<{
|
|
|
5
5
|
reason: z.ZodString;
|
|
6
6
|
prompt: z.ZodString;
|
|
7
7
|
}, "strip", z.ZodTypeAny, {
|
|
8
|
-
prompt: string;
|
|
9
8
|
reason: string;
|
|
9
|
+
prompt: string;
|
|
10
10
|
delaySeconds: number;
|
|
11
11
|
}, {
|
|
12
|
-
prompt: string;
|
|
13
12
|
reason: string;
|
|
13
|
+
prompt: string;
|
|
14
14
|
delaySeconds: number;
|
|
15
15
|
}>;
|
|
16
16
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhijiewang/openharness",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
4
4
|
"description": "Open-source terminal coding agent. Works with any LLM.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"ink-spinner": "^5.0.0",
|
|
45
45
|
"ink-text-input": "^6.0.0",
|
|
46
46
|
"marked": "^17.0.5",
|
|
47
|
+
"open": "^11.0.0",
|
|
47
48
|
"react": "^18.3.1",
|
|
48
49
|
"yaml": "^2.7.0",
|
|
49
50
|
"zod": "^3.24.0"
|