@sweny-ai/core 0.1.189 → 0.1.190
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/harness/tool-bridge/protocol.d.ts +6 -2
- package/dist/harness/tool-bridge/protocol.js +9 -1
- package/dist/journal.d.ts +15 -0
- package/dist/journal.js +56 -2
- package/dist/safe-outputs.d.ts +1 -1
- package/dist/safe-outputs.js +17 -2
- package/dist/skills/git-push.d.ts +6 -0
- package/dist/skills/git-push.js +1 -1
- package/dist/skills/github.d.ts +2 -1
- package/dist/skills/github.js +105 -6
- package/dist/types.d.ts +31 -0
- package/dist/types.js +7 -0
- package/package.json +1 -1
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* same `isError` status.
|
|
17
17
|
*/
|
|
18
18
|
import { z } from "zod";
|
|
19
|
-
import type
|
|
19
|
+
import { type JSONSchema, type Tool, type ToolContext } from "../../types.js";
|
|
20
20
|
export declare const BRIDGE_PROTOCOL_VERSION = 1;
|
|
21
21
|
/** Frames longer than this are refused and the connection is dropped. */
|
|
22
22
|
export declare const MAX_FRAME_BYTES: number;
|
|
@@ -73,7 +73,11 @@ export declare function createFrameReader(onFrame: (value: unknown) => void, onO
|
|
|
73
73
|
export declare function tokenMatches(expected: string, given: unknown): boolean;
|
|
74
74
|
/** Structural check of an incoming request frame (token checked separately). */
|
|
75
75
|
export declare function parseRequest(value: unknown): BridgeRequest | undefined;
|
|
76
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* A handler's return value as MCP content. Strings pass through; anything else
|
|
78
|
+
* is JSON. A {@link ToolRefusal} is a tool error (#544): the agent and the
|
|
79
|
+
* tool-call status (and so evals) see that the write did not happen.
|
|
80
|
+
*/
|
|
77
81
|
export declare function toolOutputToMcpResult(output: unknown): McpCallToolResult;
|
|
78
82
|
/** A handler's thrown error as an MCP error result. */
|
|
79
83
|
export declare function toolErrorToMcpResult(err: unknown): McpCallToolResult;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { timingSafeEqual } from "node:crypto";
|
|
19
19
|
import { z } from "zod";
|
|
20
|
+
import { isToolRefusal } from "../../types.js";
|
|
20
21
|
export const BRIDGE_PROTOCOL_VERSION = 1;
|
|
21
22
|
/** Frames longer than this are refused and the connection is dropped. */
|
|
22
23
|
export const MAX_FRAME_BYTES = 8 * 1024 * 1024;
|
|
@@ -86,8 +87,15 @@ export function parseRequest(value) {
|
|
|
86
87
|
return value;
|
|
87
88
|
}
|
|
88
89
|
// ─── Tool result shape (shared with the in-process Claude path) ─────
|
|
89
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* A handler's return value as MCP content. Strings pass through; anything else
|
|
92
|
+
* is JSON. A {@link ToolRefusal} is a tool error (#544): the agent and the
|
|
93
|
+
* tool-call status (and so evals) see that the write did not happen.
|
|
94
|
+
*/
|
|
90
95
|
export function toolOutputToMcpResult(output) {
|
|
96
|
+
if (isToolRefusal(output)) {
|
|
97
|
+
return { content: [{ type: "text", text: `Refused: ${output.reason}\n${JSON.stringify(output)}` }], isError: true };
|
|
98
|
+
}
|
|
91
99
|
return {
|
|
92
100
|
content: [{ type: "text", text: typeof output === "string" ? output : JSON.stringify(output) }],
|
|
93
101
|
};
|
package/dist/journal.d.ts
CHANGED
|
@@ -47,6 +47,8 @@
|
|
|
47
47
|
* write intents, written before the write stage runs
|
|
48
48
|
* output:intent a write is about to be applied (idempotency key + tool)
|
|
49
49
|
* output:applied the write returned (key + the ids it produced)
|
|
50
|
+
* output:refused the write's handler returned a refusal (#544): key, its
|
|
51
|
+
* fixed-wording reason and any ids it names; never applied
|
|
50
52
|
* node:end final result of the visit and the write-stage counters
|
|
51
53
|
* route the edge taken after the visit (null = the run ended), and the rung that chose it
|
|
52
54
|
* usage spend of one agent attempt (cumulative; one per model turn that raised it, written
|
|
@@ -84,6 +86,15 @@
|
|
|
84
86
|
* key with a receipt is not re-applied, and a key with an intent but no receipt
|
|
85
87
|
* (the process died between the API call and the receipt) is looked up on the
|
|
86
88
|
* provider by a marker embedded in the body before anything is re-sent.
|
|
89
|
+
*
|
|
90
|
+
* A handler that returns a refusal (`ToolRefusal`) gets `output:refused`
|
|
91
|
+
* instead of `output:applied`. On resume that refusal is replayed as is: the
|
|
92
|
+
* handler is not called and the provider is not searched (a marked write the
|
|
93
|
+
* refusal named would otherwise be taken for an applied one). The one
|
|
94
|
+
* exception is a refusal marked `transient` that names nothing it produced:
|
|
95
|
+
* nothing was written, so it goes through the unconfirmed-intent path above
|
|
96
|
+
* (provider lookup, then a first send). `--allow-repeat-writes` does not
|
|
97
|
+
* override a refusal: its outcome is known, not unconfirmed.
|
|
87
98
|
*/
|
|
88
99
|
import type { Logger, NodeResult, NodeUsage, RouteRung, Skill, Workflow } from "./types.js";
|
|
89
100
|
import type { SafeOutputIntent, WriteStageState } from "./safe-outputs.js";
|
|
@@ -335,6 +346,8 @@ export interface PlannedVisit {
|
|
|
335
346
|
rung?: RouteRung;
|
|
336
347
|
/** Writes the checkpointed write stage already applied (receipts in the journal). */
|
|
337
348
|
applied: number;
|
|
349
|
+
/** Writes whose handler refused (#544): the refusal is replayed, not re-sent. */
|
|
350
|
+
refused: number;
|
|
338
351
|
/** Writes with an intent but no receipt: checked on the provider before anything is re-sent. */
|
|
339
352
|
unconfirmed: number;
|
|
340
353
|
}
|
|
@@ -356,6 +369,8 @@ export interface ResumePlan {
|
|
|
356
369
|
ends: Map<string, EndRecord>;
|
|
357
370
|
intents: Map<string, OutputRecord>;
|
|
358
371
|
receipts: Map<string, OutputRecord>;
|
|
372
|
+
/** Writes whose handler refused (`output:refused`, #544): replayed on resume, never taken for applied. */
|
|
373
|
+
refusals: Map<string, OutputRecord>;
|
|
359
374
|
/** Spend every earlier attempt journaled: the run budget starts here on resume. */
|
|
360
375
|
priorSpend: Spend;
|
|
361
376
|
/**
|
package/dist/journal.js
CHANGED
|
@@ -47,6 +47,8 @@
|
|
|
47
47
|
* write intents, written before the write stage runs
|
|
48
48
|
* output:intent a write is about to be applied (idempotency key + tool)
|
|
49
49
|
* output:applied the write returned (key + the ids it produced)
|
|
50
|
+
* output:refused the write's handler returned a refusal (#544): key, its
|
|
51
|
+
* fixed-wording reason and any ids it names; never applied
|
|
50
52
|
* node:end final result of the visit and the write-stage counters
|
|
51
53
|
* route the edge taken after the visit (null = the run ended), and the rung that chose it
|
|
52
54
|
* usage spend of one agent attempt (cumulative; one per model turn that raised it, written
|
|
@@ -84,11 +86,21 @@
|
|
|
84
86
|
* key with a receipt is not re-applied, and a key with an intent but no receipt
|
|
85
87
|
* (the process died between the API call and the receipt) is looked up on the
|
|
86
88
|
* provider by a marker embedded in the body before anything is re-sent.
|
|
89
|
+
*
|
|
90
|
+
* A handler that returns a refusal (`ToolRefusal`) gets `output:refused`
|
|
91
|
+
* instead of `output:applied`. On resume that refusal is replayed as is: the
|
|
92
|
+
* handler is not called and the provider is not searched (a marked write the
|
|
93
|
+
* refusal named would otherwise be taken for an applied one). The one
|
|
94
|
+
* exception is a refusal marked `transient` that names nothing it produced:
|
|
95
|
+
* nothing was written, so it goes through the unconfirmed-intent path above
|
|
96
|
+
* (provider lookup, then a first send). `--allow-repeat-writes` does not
|
|
97
|
+
* override a refusal: its outcome is known, not unconfirmed.
|
|
87
98
|
*/
|
|
88
99
|
import crypto from "node:crypto";
|
|
89
100
|
import fs from "node:fs";
|
|
90
101
|
import os from "node:os";
|
|
91
102
|
import path from "node:path";
|
|
103
|
+
import { isToolRefusal } from "./types.js";
|
|
92
104
|
import { resolveNodePermissions } from "./node-policy.js";
|
|
93
105
|
import { CURRENT_SPEC_VERSION } from "./migrations.js";
|
|
94
106
|
import { spendOf } from "./budget.js";
|
|
@@ -840,6 +852,7 @@ const RECORD_TYPES = new Set([
|
|
|
840
852
|
"node:checkpoint",
|
|
841
853
|
"output:intent",
|
|
842
854
|
"output:applied",
|
|
855
|
+
"output:refused",
|
|
843
856
|
"node:end",
|
|
844
857
|
"route",
|
|
845
858
|
"usage",
|
|
@@ -937,6 +950,7 @@ export function checkRecordSequence(records) {
|
|
|
937
950
|
}
|
|
938
951
|
case "output:intent":
|
|
939
952
|
case "output:applied":
|
|
953
|
+
case "output:refused":
|
|
940
954
|
inVisit(r);
|
|
941
955
|
if (!checkpointed.has(visitKey(r.node, r.iteration))) {
|
|
942
956
|
bad(r, "records a write before the visit's checkpoint");
|
|
@@ -1057,6 +1071,7 @@ export function buildResumePlan(records) {
|
|
|
1057
1071
|
const byKey = new Map();
|
|
1058
1072
|
const intents = new Map();
|
|
1059
1073
|
const receipts = new Map();
|
|
1074
|
+
const refusals = new Map();
|
|
1060
1075
|
let attempts = 1;
|
|
1061
1076
|
let lastStatus;
|
|
1062
1077
|
let deciderCounters;
|
|
@@ -1113,6 +1128,9 @@ export function buildResumePlan(records) {
|
|
|
1113
1128
|
case "output:applied":
|
|
1114
1129
|
receipts.set(r.key, r);
|
|
1115
1130
|
break;
|
|
1131
|
+
case "output:refused":
|
|
1132
|
+
refusals.set(r.key, r);
|
|
1133
|
+
break;
|
|
1116
1134
|
case "run:end":
|
|
1117
1135
|
lastStatus = r.status;
|
|
1118
1136
|
break;
|
|
@@ -1129,7 +1147,8 @@ export function buildResumePlan(records) {
|
|
|
1129
1147
|
const complete = !!v.end && !(isLast && v.end.result.status === "failed" && typeof v.next !== "string");
|
|
1130
1148
|
const keys = (rec) => [...rec.values()].filter((o) => o.node === v.node && o.iteration === v.iteration).map((o) => o.key);
|
|
1131
1149
|
const applied = keys(receipts).length;
|
|
1132
|
-
const
|
|
1150
|
+
const refused = keys(refusals).filter((k) => !receipts.has(k)).length;
|
|
1151
|
+
const unconfirmed = keys(intents).filter((k) => !receipts.has(k) && !refusals.has(k)).length;
|
|
1133
1152
|
if (complete && !resumed) {
|
|
1134
1153
|
visits.push({
|
|
1135
1154
|
node: v.node,
|
|
@@ -1139,6 +1158,7 @@ export function buildResumePlan(records) {
|
|
|
1139
1158
|
next: v.next,
|
|
1140
1159
|
...(v.rung ? { rung: v.rung } : {}),
|
|
1141
1160
|
applied,
|
|
1161
|
+
refused,
|
|
1142
1162
|
unconfirmed,
|
|
1143
1163
|
});
|
|
1144
1164
|
writeState = v.end.write_state;
|
|
@@ -1150,6 +1170,7 @@ export function buildResumePlan(records) {
|
|
|
1150
1170
|
iteration: v.iteration,
|
|
1151
1171
|
action: v.checkpoint ? "write-stage" : "rerun",
|
|
1152
1172
|
applied,
|
|
1173
|
+
refused,
|
|
1153
1174
|
unconfirmed,
|
|
1154
1175
|
});
|
|
1155
1176
|
break;
|
|
@@ -1180,6 +1201,7 @@ export function buildResumePlan(records) {
|
|
|
1180
1201
|
ends,
|
|
1181
1202
|
intents,
|
|
1182
1203
|
receipts,
|
|
1204
|
+
refusals,
|
|
1183
1205
|
priorSpend: journaledSpend(records),
|
|
1184
1206
|
unsandboxed: [...new Set(records.filter((r) => r.type === "agent:unsandboxed").map((r) => String(r.node)))],
|
|
1185
1207
|
...(start.decider
|
|
@@ -1336,6 +1358,14 @@ function slimOutput(o, depth = 0) {
|
|
|
1336
1358
|
if (!o || typeof o !== "object" || Array.isArray(o) || depth > 3)
|
|
1337
1359
|
return null;
|
|
1338
1360
|
const out = {};
|
|
1361
|
+
// A refusal keeps what it is and its fixed-wording reason (#544); never `detail`.
|
|
1362
|
+
if (depth === 0 && o.refused === true) {
|
|
1363
|
+
out.refused = true;
|
|
1364
|
+
if (isToolRefusal(o))
|
|
1365
|
+
out.reason = o.reason;
|
|
1366
|
+
if (o.transient === true)
|
|
1367
|
+
out.transient = true;
|
|
1368
|
+
}
|
|
1339
1369
|
for (const [k, v] of Object.entries(o)) {
|
|
1340
1370
|
if (["number", "identifier", "id", "html_url", "url", "success", "reused"].includes(k)) {
|
|
1341
1371
|
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean")
|
|
@@ -1805,6 +1835,12 @@ export class RunJournal {
|
|
|
1805
1835
|
this.logger.info(` run journal: ${tool.name} already applied before the crash; not re-sent`, { node });
|
|
1806
1836
|
return receipt.output ?? null;
|
|
1807
1837
|
}
|
|
1838
|
+
const refusal = plan?.refusals.get(key);
|
|
1839
|
+
if (refusal && !retriesRefusal(refusal.output)) {
|
|
1840
|
+
this.logger.info(` run journal: ${tool.name} was refused in an earlier attempt; not re-sent`, { node });
|
|
1841
|
+
const prior = refusal.output && typeof refusal.output === "object" ? refusal.output : {};
|
|
1842
|
+
return { reason: "the tool refused the write", ...prior, refused: true };
|
|
1843
|
+
}
|
|
1808
1844
|
const replayingStage = !!plan?.checkpoints.has(visitKey(node, iteration));
|
|
1809
1845
|
const pending = plan?.intents.has(key) === true;
|
|
1810
1846
|
if (pending || replayingStage) {
|
|
@@ -1826,12 +1862,30 @@ export class RunJournal {
|
|
|
1826
1862
|
// A write the journal cannot record is never sent: append throws first.
|
|
1827
1863
|
this.append("output:intent", { node, iteration, key, tool: tool.name });
|
|
1828
1864
|
const output = await tool.handler(withMarker(tool.name, args, key), ctx);
|
|
1829
|
-
|
|
1865
|
+
const refused = !!output && typeof output === "object" && output.refused === true;
|
|
1866
|
+
this.append(refused ? "output:refused" : "output:applied", {
|
|
1867
|
+
node,
|
|
1868
|
+
iteration,
|
|
1869
|
+
key,
|
|
1870
|
+
tool: tool.name,
|
|
1871
|
+
output: slimOutput(output),
|
|
1872
|
+
});
|
|
1830
1873
|
return output;
|
|
1831
1874
|
},
|
|
1832
1875
|
};
|
|
1833
1876
|
}
|
|
1834
1877
|
}
|
|
1878
|
+
/**
|
|
1879
|
+
* Whether a journaled refusal is tried again on resume (#544): only a
|
|
1880
|
+
* transient one that names nothing it produced. Anything else stands.
|
|
1881
|
+
*/
|
|
1882
|
+
function retriesRefusal(output) {
|
|
1883
|
+
if (!output || typeof output !== "object")
|
|
1884
|
+
return false;
|
|
1885
|
+
const o = output;
|
|
1886
|
+
// Anything besides the refusal itself is something the write produced.
|
|
1887
|
+
return o.transient === true && Object.keys(o).every((k) => k === "refused" || k === "reason" || k === "transient");
|
|
1888
|
+
}
|
|
1835
1889
|
/** What a node result needs to continue a run. Tool call inputs and outputs are dropped. */
|
|
1836
1890
|
function journalResult(r) {
|
|
1837
1891
|
return {
|
package/dist/safe-outputs.d.ts
CHANGED
|
@@ -114,7 +114,7 @@ export interface ApplySafeOutputsOptions {
|
|
|
114
114
|
}
|
|
115
115
|
export interface ApplySafeOutputsResult {
|
|
116
116
|
receipts: SafeOutputReceipt[];
|
|
117
|
-
/** Set when an apply call failed; the node fails with it. */
|
|
117
|
+
/** Set when an apply call failed or its handler refused (a `ToolRefusal`); the node fails with it. */
|
|
118
118
|
error?: string;
|
|
119
119
|
}
|
|
120
120
|
/** Check, then apply (or stage) one node's intents. See the module comment for the order of checks. */
|
package/dist/safe-outputs.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { createHash } from "node:crypto";
|
|
17
17
|
import { readFileSync } from "node:fs";
|
|
18
|
-
import { SAFE_OUTPUT_APPLIERS } from "./types.js";
|
|
18
|
+
import { SAFE_OUTPUT_APPLIERS, isToolRefusal } from "./types.js";
|
|
19
19
|
/** Name of the intent-recording tool a node with `outputs` receives. */
|
|
20
20
|
export const EMIT_OUTPUT_TOOL = "emit_output";
|
|
21
21
|
/** Longest title the write stage accepts (after the prefix). */
|
|
@@ -574,7 +574,22 @@ export async function applySafeOutputs(o) {
|
|
|
574
574
|
});
|
|
575
575
|
const ref = refOf(output);
|
|
576
576
|
const url = urlOf(output);
|
|
577
|
-
|
|
577
|
+
const produced = { ...(ref !== undefined ? { ref } : {}), ...(url ? { url } : {}) };
|
|
578
|
+
const refused = !!output && typeof output === "object" && output.refused === true;
|
|
579
|
+
if (refused) {
|
|
580
|
+
// #544: the handler declined the write, or could not vouch for it. The
|
|
581
|
+
// receipt says refused with the handler's fixed-wording reason (and
|
|
582
|
+
// names anything it did create); the node fails and the rest are
|
|
583
|
+
// skipped, the same as a failed apply. A refusal without a usable
|
|
584
|
+
// reason still counts as one.
|
|
585
|
+
const reason = isToolRefusal(output) ? output.reason.slice(0, 200) : "the tool refused the write";
|
|
586
|
+
receipts[index] = { ...receipts[index], ...produced, status: "refused", reason };
|
|
587
|
+
for (const rest of accepted.slice(n + 1)) {
|
|
588
|
+
receipts[rest.index] = { ...receipts[rest.index], status: "skipped", reason: "an earlier write was refused" };
|
|
589
|
+
}
|
|
590
|
+
return { receipts, error: `safe output ${write.type} via ${write.via} was refused: ${reason}` };
|
|
591
|
+
}
|
|
592
|
+
receipts[index] = { ...receipts[index], ...produced };
|
|
578
593
|
o.logger.info(` safe output: ${write.type} via ${write.via} applied${ref !== undefined ? ` (${ref})` : ""}`, {
|
|
579
594
|
node: o.nodeId,
|
|
580
595
|
});
|
|
@@ -110,6 +110,12 @@ export interface PushHeadResult {
|
|
|
110
110
|
attempted: boolean;
|
|
111
111
|
/** Why nothing was pushed, or why the push failed. Never carries the token. */
|
|
112
112
|
reason?: string;
|
|
113
|
+
/**
|
|
114
|
+
* The local commit the push tried to publish. Set when the push ran and
|
|
115
|
+
* failed: `github_create_pr` then opens the PR only if the remote head is
|
|
116
|
+
* this commit (#544).
|
|
117
|
+
*/
|
|
118
|
+
sha?: string;
|
|
113
119
|
}
|
|
114
120
|
/** `owner/repo` from a GitHub remote URL (https, ssh or scp form), lowercased, or undefined. */
|
|
115
121
|
export declare function repoFromRemote(url: string): {
|
package/dist/skills/git-push.js
CHANGED
|
@@ -459,7 +459,7 @@ export async function pushHeadBranch(opts) {
|
|
|
459
459
|
], pushEnv);
|
|
460
460
|
if (r.code !== 0) {
|
|
461
461
|
const why = (r.stderr || r.stdout).trim().split("\n").at(-1) ?? "";
|
|
462
|
-
return { pushed: false, attempted: true, reason: `git push failed: ${redact(why, token)}
|
|
462
|
+
return { pushed: false, attempted: true, reason: `git push failed: ${redact(why, token)}`, sha };
|
|
463
463
|
}
|
|
464
464
|
return { pushed: true, attempted: true };
|
|
465
465
|
}
|
package/dist/skills/github.d.ts
CHANGED
|
@@ -19,12 +19,13 @@ export declare function githubApiBase(apiUrl: string | undefined): string;
|
|
|
19
19
|
* remotely is pushed with an empty lease (it must still not exist). One that
|
|
20
20
|
* exists is pushed only when it is unprotected and already has an open PR from
|
|
21
21
|
* this repo into `base`, leased on the sha the API returned. Any API failure
|
|
22
|
-
* means no push.
|
|
22
|
+
* means no push. A skip carries the remote head sha when the API returned one.
|
|
23
23
|
*/
|
|
24
24
|
export declare function headPushLease(repo: string, head: string, base: string, ctx: ToolContext): Promise<{
|
|
25
25
|
remoteSha: string;
|
|
26
26
|
} | {
|
|
27
27
|
skip: string;
|
|
28
|
+
remoteSha?: string;
|
|
28
29
|
}>;
|
|
29
30
|
/**
|
|
30
31
|
* Encode an LLM-chosen repo file path for safe interpolation into a
|
package/dist/skills/github.js
CHANGED
|
@@ -56,7 +56,7 @@ async function gh(path, ctx, init) {
|
|
|
56
56
|
* remotely is pushed with an empty lease (it must still not exist). One that
|
|
57
57
|
* exists is pushed only when it is unprotected and already has an open PR from
|
|
58
58
|
* this repo into `base`, leased on the sha the API returned. Any API failure
|
|
59
|
-
* means no push.
|
|
59
|
+
* means no push. A skip carries the remote head sha when the API returned one.
|
|
60
60
|
*/
|
|
61
61
|
export async function headPushLease(repo, head, base, ctx) {
|
|
62
62
|
const branch = head.replace(/^refs\/heads\//, "");
|
|
@@ -76,7 +76,7 @@ export async function headPushLease(repo, head, base, ctx) {
|
|
|
76
76
|
return { skip: `cannot check head ${branch} on ${repo}: no branch sha in the API answer` };
|
|
77
77
|
}
|
|
78
78
|
if (info.protected !== false)
|
|
79
|
-
return { skip: `head ${branch} exists on ${repo} and is protected
|
|
79
|
+
return { skip: `head ${branch} exists on ${repo} and is protected`, remoteSha: sha };
|
|
80
80
|
const owner = repo.split("/")[0];
|
|
81
81
|
let open;
|
|
82
82
|
try {
|
|
@@ -85,13 +85,45 @@ export async function headPushLease(repo, head, base, ctx) {
|
|
|
85
85
|
catch (err) {
|
|
86
86
|
return {
|
|
87
87
|
skip: `cannot check open PRs for ${branch} on ${repo}: ${err instanceof Error ? err.message : String(err)}`,
|
|
88
|
+
remoteSha: sha,
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
91
|
if (!Array.isArray(open) || open.length === 0) {
|
|
91
|
-
return { skip: `head ${branch} already exists on ${repo} with no open PR into ${target}
|
|
92
|
+
return { skip: `head ${branch} already exists on ${repo} with no open PR into ${target}`, remoteSha: sha };
|
|
92
93
|
}
|
|
93
94
|
return { remoteSha: sha };
|
|
94
95
|
}
|
|
96
|
+
const SHA = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
97
|
+
// Fixed-wording refusal reasons for `github_create_pr` (#544). Receipts and PR
|
|
98
|
+
// comments carry these; shas and upstream errors go in the result's `detail`.
|
|
99
|
+
const REFUSE_NO_LOCAL = "the head push failed and the pusher did not report the local commit";
|
|
100
|
+
const REFUSE_UNREADABLE = "the head push failed and the remote head could not be read";
|
|
101
|
+
const REFUSE_MISMATCH = "the head push failed and the remote head is not the commit this run produced";
|
|
102
|
+
const REFUSE_PR_MISMATCH = "the PR was opened but its head is not the commit this run produced";
|
|
103
|
+
const REFUSE_PR_UNVERIFIED = "the PR was opened but its head commit could not be verified";
|
|
104
|
+
/**
|
|
105
|
+
* The sha the remote `head` branch points at, read through the API (#544), or
|
|
106
|
+
* an error. A missing branch is `{ sha: null }`; anything unreadable is an error,
|
|
107
|
+
* so the caller fails closed.
|
|
108
|
+
*/
|
|
109
|
+
async function remoteHeadSha(repo, head, ctx) {
|
|
110
|
+
const branch = head.replace(/^refs\/heads\//, "");
|
|
111
|
+
const seg = branch.split("/").map(encodeURIComponent).join("/");
|
|
112
|
+
let ref;
|
|
113
|
+
try {
|
|
114
|
+
ref = (await gh(`/repos/${repo}/git/ref/heads/${seg}`, ctx));
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
if (err instanceof GitHubApiError && err.status === 404)
|
|
118
|
+
return { sha: null };
|
|
119
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
120
|
+
}
|
|
121
|
+
const sha = ref?.object?.sha;
|
|
122
|
+
if (ref?.ref !== `refs/heads/${branch}` || typeof sha !== "string" || !SHA.test(sha)) {
|
|
123
|
+
return { error: "no matching ref in the API answer" };
|
|
124
|
+
}
|
|
125
|
+
return { sha };
|
|
126
|
+
}
|
|
95
127
|
function isAlreadyExistsError(err) {
|
|
96
128
|
if (!(err instanceof GitHubApiError) || err.status !== 422)
|
|
97
129
|
return false;
|
|
@@ -280,6 +312,10 @@ export const github = {
|
|
|
280
312
|
handler: async (input, ctx) => {
|
|
281
313
|
let pr;
|
|
282
314
|
let reused = false;
|
|
315
|
+
// The remote head this run saw, for the result (#544).
|
|
316
|
+
let observedHead;
|
|
317
|
+
// Set after sweny's own push failed: the PR's head must be this commit (#544).
|
|
318
|
+
let mustHead;
|
|
283
319
|
// #473: push the head branch from sweny with this skill's token, so the
|
|
284
320
|
// recommended `persist-credentials: false` checkout still ships the
|
|
285
321
|
// branch and the agent never holds a write token. Skipped unless the
|
|
@@ -307,12 +343,49 @@ export const github = {
|
|
|
307
343
|
remoteSha: lease.remoteSha,
|
|
308
344
|
token: ctx.config.GITHUB_TOKEN,
|
|
309
345
|
});
|
|
346
|
+
if (lease.remoteSha)
|
|
347
|
+
observedHead = lease.remoteSha;
|
|
310
348
|
if ("skip" in lease)
|
|
311
349
|
ctx.logger?.warn?.(` github_create_pr: not pushing: ${lease.skip}`);
|
|
312
350
|
else if (push.pushed)
|
|
313
351
|
ctx.logger?.info?.(` github_create_pr: pushed ${input.head} to ${input.repo}`);
|
|
314
|
-
else if (push.attempted)
|
|
315
|
-
|
|
352
|
+
else if (push.attempted) {
|
|
353
|
+
// #544: a push sweny ran and that failed. The PR would publish
|
|
354
|
+
// whatever the remote branch holds, so open it only when that is
|
|
355
|
+
// the commit this run would have pushed. Anything else, or a
|
|
356
|
+
// remote head that cannot be read, refuses.
|
|
357
|
+
const local = typeof push.sha === "string" && SHA.test(push.sha) ? push.sha : null;
|
|
358
|
+
const remote = local ? await remoteHeadSha(input.repo, input.head, ctx) : undefined;
|
|
359
|
+
const remoteSha = remote && "sha" in remote ? remote.sha : null;
|
|
360
|
+
if (!local || !remoteSha || remoteSha !== local) {
|
|
361
|
+
const [reason, why] = !local
|
|
362
|
+
? [REFUSE_NO_LOCAL, "the pusher did not report the local commit"]
|
|
363
|
+
: remote && "error" in remote
|
|
364
|
+
? [REFUSE_UNREADABLE, `cannot read the remote head of ${input.head}: ${remote.error}`]
|
|
365
|
+
: remoteSha
|
|
366
|
+
? [
|
|
367
|
+
REFUSE_MISMATCH,
|
|
368
|
+
`the remote head of ${input.head} is ${remoteSha}, not the local commit ${local}`,
|
|
369
|
+
]
|
|
370
|
+
: [REFUSE_MISMATCH, `${input.head} does not exist on ${input.repo}; the local commit is ${local}`];
|
|
371
|
+
const detail = `sweny's push failed (${push.reason}) and ${why}`;
|
|
372
|
+
ctx.logger?.warn?.(` github_create_pr: refusing to open the PR: ${detail}`);
|
|
373
|
+
const refusal = {
|
|
374
|
+
refused: true,
|
|
375
|
+
reason,
|
|
376
|
+
detail,
|
|
377
|
+
// A ref read that failed may succeed later; nothing was written.
|
|
378
|
+
...(reason === REFUSE_UNREADABLE ? { transient: true } : {}),
|
|
379
|
+
head: input.head,
|
|
380
|
+
local_sha: local,
|
|
381
|
+
remote_sha: remoteSha,
|
|
382
|
+
};
|
|
383
|
+
return refusal;
|
|
384
|
+
}
|
|
385
|
+
observedHead = remoteSha;
|
|
386
|
+
mustHead = local;
|
|
387
|
+
ctx.logger?.warn?.(` github_create_pr: ${push.reason}; the remote head is already the local commit ${local}, requesting the PR`);
|
|
388
|
+
}
|
|
316
389
|
else
|
|
317
390
|
ctx.logger?.debug?.(` github_create_pr: no sweny-side push (${push.reason})`);
|
|
318
391
|
}
|
|
@@ -347,6 +420,29 @@ export const github = {
|
|
|
347
420
|
pr = existing;
|
|
348
421
|
reused = true;
|
|
349
422
|
}
|
|
423
|
+
// #544: the remote could move between the ref read and the request. The
|
|
424
|
+
// PR exists now; it is not closed (that would be another write nobody
|
|
425
|
+
// asked for), but the result refuses to vouch for it.
|
|
426
|
+
const prHead = pr.head?.sha;
|
|
427
|
+
const prHeadSha = typeof prHead === "string" && SHA.test(prHead) ? prHead : null;
|
|
428
|
+
if (mustHead && prHeadSha !== mustHead) {
|
|
429
|
+
const detail = prHeadSha
|
|
430
|
+
? `the PR's head is ${prHeadSha}, not the local commit ${mustHead}`
|
|
431
|
+
: `GitHub did not report the PR's head commit; the local commit is ${mustHead}`;
|
|
432
|
+
ctx.logger?.warn?.(` github_create_pr: ${pr.html_url ?? `#${pr.number}`} is not verified: ${detail}`);
|
|
433
|
+
const refusal = {
|
|
434
|
+
refused: true,
|
|
435
|
+
reason: prHeadSha ? REFUSE_PR_MISMATCH : REFUSE_PR_UNVERIFIED,
|
|
436
|
+
detail,
|
|
437
|
+
...(pr.number !== undefined ? { number: pr.number } : {}),
|
|
438
|
+
...(pr.html_url ? { html_url: pr.html_url } : {}),
|
|
439
|
+
...(reused ? { reused: true } : {}),
|
|
440
|
+
head: input.head,
|
|
441
|
+
local_sha: mustHead,
|
|
442
|
+
remote_sha: prHeadSha,
|
|
443
|
+
};
|
|
444
|
+
return refusal;
|
|
445
|
+
}
|
|
350
446
|
try {
|
|
351
447
|
if (pr.number && !reused) {
|
|
352
448
|
await gh(`/repos/${input.repo}/issues/${pr.number}/labels`, ctx, {
|
|
@@ -364,7 +460,10 @@ export const github = {
|
|
|
364
460
|
const msg = err instanceof Error ? err.message : String(err);
|
|
365
461
|
ctx.logger?.warn?.(` github_create_pr: label POST failed for ${pr.html_url ?? `#${pr.number}`} — ${msg}`);
|
|
366
462
|
}
|
|
367
|
-
|
|
463
|
+
// The head the PR was opened from: GitHub's answer, else the remote head this run read.
|
|
464
|
+
const headSha = prHeadSha ?? observedHead;
|
|
465
|
+
const out = headSha ? { ...pr, head_sha: headSha } : pr;
|
|
466
|
+
return reused ? { ...out, reused: true } : out;
|
|
368
467
|
},
|
|
369
468
|
},
|
|
370
469
|
{
|
package/dist/types.d.ts
CHANGED
|
@@ -48,6 +48,11 @@ export type BranchPusher = (opts: {
|
|
|
48
48
|
pushed: boolean;
|
|
49
49
|
attempted: boolean;
|
|
50
50
|
reason?: string;
|
|
51
|
+
/**
|
|
52
|
+
* The local commit the push tried to publish. Set when a push was attempted
|
|
53
|
+
* and failed, so the caller can check what the remote head holds (#544).
|
|
54
|
+
*/
|
|
55
|
+
sha?: string;
|
|
51
56
|
}>;
|
|
52
57
|
/**
|
|
53
58
|
* Side-effect class of a tool. `"read"` only reads. `"write"` can change
|
|
@@ -68,6 +73,32 @@ export interface Tool {
|
|
|
68
73
|
access?: ToolAccess;
|
|
69
74
|
handler: (input: any, ctx: ToolContext) => Promise<unknown>;
|
|
70
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* A write handler's structured refusal: it declined the write, or could not
|
|
78
|
+
* vouch for what it wrote, and says why. Returned, not thrown, so the caller
|
|
79
|
+
* keeps the details. `refused: true` is the same flag a refused node result
|
|
80
|
+
* carries (`data: { error, refused: true }`). The write stage records it as
|
|
81
|
+
* refused, never applied (#544).
|
|
82
|
+
*
|
|
83
|
+
* `reason` is fixed wording chosen by the handler, never model, agent or
|
|
84
|
+
* remote text: receipts and PR comments carry it. Specifics (shas, branch
|
|
85
|
+
* names, upstream errors) go in `detail` and other fields, which only the
|
|
86
|
+
* tool result carries. A refusal may still name what exists (`number`,
|
|
87
|
+
* `html_url`) when the write happened but could not be verified.
|
|
88
|
+
*/
|
|
89
|
+
export interface ToolRefusal {
|
|
90
|
+
refused: true;
|
|
91
|
+
reason: string;
|
|
92
|
+
detail?: string;
|
|
93
|
+
/**
|
|
94
|
+
* The refusal may not hold on a later try (an upstream read failed) and
|
|
95
|
+
* nothing was written. Only such a refusal is tried again when a run is
|
|
96
|
+
* resumed; any other refusal is final for the run.
|
|
97
|
+
*/
|
|
98
|
+
transient?: boolean;
|
|
99
|
+
}
|
|
100
|
+
/** Whether a tool handler's output is a {@link ToolRefusal}. */
|
|
101
|
+
export declare function isToolRefusal(output: unknown): output is ToolRefusal;
|
|
71
102
|
/** Config field declaration — skills say what they need */
|
|
72
103
|
export interface ConfigField {
|
|
73
104
|
description: string;
|
package/dist/types.js
CHANGED
|
@@ -4,6 +4,13 @@ export { WORKFLOW_INPUT_TYPES } from "./inputs.js";
|
|
|
4
4
|
* something outside the run: create, update, delete, post, send, invoke.
|
|
5
5
|
*/
|
|
6
6
|
export const TOOL_ACCESS = ["read", "write"];
|
|
7
|
+
/** Whether a tool handler's output is a {@link ToolRefusal}. */
|
|
8
|
+
export function isToolRefusal(output) {
|
|
9
|
+
if (!output || typeof output !== "object" || Array.isArray(output))
|
|
10
|
+
return false;
|
|
11
|
+
const o = output;
|
|
12
|
+
return o.refused === true && typeof o.reason === "string" && o.reason.length > 0;
|
|
13
|
+
}
|
|
7
14
|
/**
|
|
8
15
|
* Skill categories: used for per-node validation and grouping.
|
|
9
16
|
*
|