@saadnvd1/agent-os 0.3.715 → 0.3.723
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 +1 -0
- package/app/api/agent-roles/route.test.ts +67 -0
- package/app/api/agent-roles/route.ts +61 -0
- package/app/api/analyst/[id]/recommendations/[recId]/again/route.ts +15 -0
- package/app/api/analyst/[id]/recommendations/[recId]/dismiss/route.ts +13 -0
- package/app/api/analyst/[id]/recommendations/[recId]/send/route.ts +13 -0
- package/app/api/analyst/[id]/review/route.ts +22 -0
- package/app/api/analyst/[id]/route.ts +44 -0
- package/app/api/analyst/route.ts +10 -0
- package/app/api/bus/spawn/route.ts +22 -1
- package/app/api/models/availability/route.ts +9 -0
- package/app/api/models/choices/route.ts +9 -0
- package/app/api/sessions/[id]/analyst/route.ts +11 -0
- package/app/api/sessions/[id]/crew/route.ts +38 -0
- package/app/api/sessions/[id]/export/estimate/route.ts +23 -0
- package/app/api/sessions/[id]/export/route.test.ts +125 -0
- package/app/api/sessions/[id]/export/route.ts +34 -0
- package/app/api/sessions/[id]/model/route.ts +49 -0
- package/app/api/sessions/route.ts +16 -0
- package/app/page.tsx +5 -0
- package/components/Chat/Messages.tsx +104 -10
- package/components/Draft/DraftChips.tsx +6 -1
- package/components/Draft/DraftPanel.tsx +51 -2
- package/components/Draft/ModelUnavailableDialog.tsx +78 -0
- package/components/Draft/RoleChips.tsx +110 -0
- package/components/Export/ExportActions.tsx +136 -0
- package/components/Export/ExportSheet.tsx +217 -0
- package/components/Export/RangePicker.tsx +144 -0
- package/components/Export/Seg.tsx +38 -0
- package/components/Export/TurnSelectBar.tsx +45 -0
- package/components/Models/AgentRoleCard.tsx +114 -0
- package/components/Models/AgentRolesSection.tsx +132 -0
- package/components/Models/ModelBadges.tsx +48 -0
- package/components/Models/ModelsSection.tsx +4 -2
- package/components/Models/RoleModelSelect.tsx +87 -0
- package/components/Pane/DesktopTabBar.tsx +2 -0
- package/components/Pane/MobileTabBar.tsx +12 -0
- package/components/Pane/PaneMenu.tsx +10 -0
- package/components/SessionList/SessionRow.tsx +18 -7
- package/components/SessionList/SessionRowMenu.tsx +6 -0
- package/components/Sessions/RolePill.tsx +196 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Share/ShareDialog.tsx +13 -0
- package/data/analyst/index.ts +3 -0
- package/data/analyst/keys.ts +6 -0
- package/data/analyst/mutations.ts +116 -0
- package/data/analyst/queries.ts +35 -0
- package/data/exports/index.ts +2 -0
- package/data/exports/keys.ts +3 -0
- package/data/exports/queries.ts +51 -0
- package/data/models/index.ts +98 -0
- package/data/push/topics.ts +5 -0
- package/data/sessions/queries.ts +26 -0
- package/lib/agents/brief.ts +1 -0
- package/lib/analyst/__fixtures__/items.ts +57 -0
- package/lib/analyst/__fixtures__/mocks.ts +49 -0
- package/lib/analyst/brief.ts +17 -0
- package/lib/analyst/buildDigest.ts +204 -0
- package/lib/analyst/createLink.ts +98 -0
- package/lib/analyst/endLink.ts +31 -0
- package/lib/analyst/forward.ts +109 -0
- package/lib/analyst/index.ts +16 -0
- package/lib/analyst/linkView.ts +38 -0
- package/lib/analyst/onTurnEnd.ts +119 -0
- package/lib/analyst/parseReview.ts +41 -0
- package/lib/analyst/pushDigest.ts +143 -0
- package/lib/analyst/resume.ts +35 -0
- package/lib/analyst/route.ts +50 -0
- package/lib/analyst/signals.ts +121 -0
- package/lib/analyst/store.ts +303 -0
- package/lib/analyst/types.ts +140 -0
- package/lib/bus/condense.ts +5 -1
- package/lib/bus/index.ts +5 -0
- package/lib/bus/read-scope.ts +12 -0
- package/lib/bus/role-alias.ts +70 -0
- package/lib/bus/targets.ts +3 -0
- package/lib/chat/drivers/opencode-mapper.ts +55 -1
- package/lib/chat/drivers/pi-approvals.ts +7 -4
- package/lib/chat/drivers/pi-mapper.ts +38 -0
- package/lib/chat/drivers/pi-text-tools.ts +104 -0
- package/lib/chat/drivers/pi.ts +8 -3
- package/lib/chat/drivers/turn-items.ts +36 -11
- package/lib/chat/drivers/turn-outcome.ts +108 -0
- package/lib/chat/events.ts +8 -0
- package/lib/chat/registry.ts +13 -0
- package/lib/chat/store.ts +19 -0
- package/lib/chat/turns.ts +1 -0
- package/lib/chat/worker/extras.ts +4 -2
- package/lib/claims/collectLocal.ts +3 -1
- package/lib/db/changes.ts +6 -0
- package/lib/db/migrations/20261021T1400_session_agent_roles.ts +20 -0
- package/lib/db/migrations/20261021T1430_analyst_links.ts +52 -0
- package/lib/db/session-parts/columns.ts +2 -0
- package/lib/db/types.ts +3 -0
- package/lib/done/archive.ts +2 -0
- package/lib/drafts.ts +4 -0
- package/lib/export/build.ts +470 -0
- package/lib/export/compact.ts +156 -0
- package/lib/export/estimate.ts +83 -0
- package/lib/export/fixtures.ts +45 -0
- package/lib/export/html.tsx +195 -0
- package/lib/export/json.ts +39 -0
- package/lib/export/load.ts +64 -0
- package/lib/export/markdown.ts +127 -0
- package/lib/export/request.ts +36 -0
- package/lib/export/service.ts +142 -0
- package/lib/export/summary.ts +86 -0
- package/lib/export/turns.ts +63 -0
- package/lib/export/view.ts +173 -0
- package/lib/models/agent-binding.ts +102 -0
- package/lib/models/agent-roles-view.ts +157 -0
- package/lib/models/agent-roles.ts +87 -0
- package/lib/models/availability-refs.ts +17 -0
- package/lib/models/availability.ts +91 -0
- package/lib/models/catalog.ts +156 -0
- package/lib/models/config.ts +27 -0
- package/lib/models/locality.ts +33 -0
- package/lib/models/providers.ts +11 -0
- package/lib/models/resolve.ts +50 -12
- package/lib/models/roles.ts +12 -0
- package/lib/needs-you.ts +1 -1
- package/lib/orchestrator/facts.ts +7 -2
- package/lib/orchestrator/read.ts +5 -1
- package/lib/orchestrator/untrusted.ts +5 -0
- package/lib/projects.ts +1 -0
- package/lib/session-artifacts/agentos-files.ts +94 -0
- package/lib/session-artifacts/collect.ts +17 -0
- package/lib/sessions/crew.ts +72 -0
- package/lib/sessions/launch.ts +49 -9
- package/lib/settings-url.ts +1 -0
- package/lib/share/markdown.ts +2 -2
- package/lib/share/redact.ts +16 -10
- package/lib/share/render.tsx +1 -1
- package/lib/share/snapshot.ts +2 -2
- package/lib/sidebar/row-menu.ts +6 -1
- package/lib/tasks/index.ts +6 -0
- package/lib/worktree-deps/caches.ts +3 -2
- package/lib/worktree-deps/clone.ts +3 -1
- package/lib/worktree-deps/paths.ts +26 -0
- package/package.json +1 -1
- package/server.ts +9 -1
- package/stores/exportUi.ts +87 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// What the analyst says the coding agent should do next.
|
|
4
|
+
export const VERDICTS = ["continue", "done", "blocked", "needs_you"] as const;
|
|
5
|
+
export type Verdict = (typeof VERDICTS)[number];
|
|
6
|
+
|
|
7
|
+
export const RECOMMENDATION_STATES = [
|
|
8
|
+
"open",
|
|
9
|
+
"sent",
|
|
10
|
+
"dismissed",
|
|
11
|
+
"superseded",
|
|
12
|
+
"invalid",
|
|
13
|
+
] as const;
|
|
14
|
+
export type RecommendationState = (typeof RECOMMENDATION_STATES)[number];
|
|
15
|
+
|
|
16
|
+
export const issueSchema = z.object({
|
|
17
|
+
severity: z.enum(["high", "medium", "low"]),
|
|
18
|
+
title: z.string().trim().min(1).max(200),
|
|
19
|
+
detail: z.string().trim().max(2_000).default(""),
|
|
20
|
+
where: z.string().trim().max(300).optional(),
|
|
21
|
+
});
|
|
22
|
+
export type Issue = z.infer<typeof issueSchema>;
|
|
23
|
+
|
|
24
|
+
export const MAX_ISSUES = 8;
|
|
25
|
+
export const MAX_PROMPT = 4_000;
|
|
26
|
+
|
|
27
|
+
// The fenced `agentos-review` block the analyst ends each review with.
|
|
28
|
+
export const reviewSchema = z.object({
|
|
29
|
+
verdict: z.enum(VERDICTS),
|
|
30
|
+
summary: z.string().trim().min(1).max(1_000),
|
|
31
|
+
issues: z.array(issueSchema).max(MAX_ISSUES).default([]),
|
|
32
|
+
prompt: z.string().trim().max(MAX_PROMPT).nullable().optional(),
|
|
33
|
+
});
|
|
34
|
+
export type Review = z.infer<typeof reviewSchema>;
|
|
35
|
+
|
|
36
|
+
// What a batch of the coding agent's turns did, worked out without a model.
|
|
37
|
+
export interface DigestSignals {
|
|
38
|
+
turns: number;
|
|
39
|
+
toolCalls: number;
|
|
40
|
+
failedTools: number;
|
|
41
|
+
// Test, build, typecheck or lint commands, and whether each passed.
|
|
42
|
+
checks: { command: string; passed: boolean }[];
|
|
43
|
+
filesChanged: string[];
|
|
44
|
+
durationMs: number;
|
|
45
|
+
costUsd: number;
|
|
46
|
+
interrupted: boolean;
|
|
47
|
+
errors: number;
|
|
48
|
+
// Turns that ended without a single tool call.
|
|
49
|
+
noToolTurns: number;
|
|
50
|
+
// Fetched web content: a prompt built on it shouldn't be sent unseen.
|
|
51
|
+
readWeb: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AnalystLink {
|
|
55
|
+
id: string;
|
|
56
|
+
coderSessionId: string;
|
|
57
|
+
analystSessionId: string;
|
|
58
|
+
paused: boolean;
|
|
59
|
+
// The coding agent's last turn_end the analyst has answered for.
|
|
60
|
+
reviewedThrough: string | null;
|
|
61
|
+
// A digest sent and not yet answered, the turn_end it runs to, and what
|
|
62
|
+
// its turns did.
|
|
63
|
+
pendingDigestId: string | null;
|
|
64
|
+
pendingThrough: string | null;
|
|
65
|
+
digestSignals: DigestSignals | null;
|
|
66
|
+
createdAt: number;
|
|
67
|
+
endedAt: number | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface Recommendation {
|
|
71
|
+
id: string;
|
|
72
|
+
linkId: string;
|
|
73
|
+
analystItemId: string;
|
|
74
|
+
coderThrough: string | null;
|
|
75
|
+
verdict: Verdict | null;
|
|
76
|
+
summary: string;
|
|
77
|
+
issues: Issue[];
|
|
78
|
+
signals: DigestSignals | null;
|
|
79
|
+
prompt: string | null;
|
|
80
|
+
state: RecommendationState;
|
|
81
|
+
// Why it's invalid.
|
|
82
|
+
error: string | null;
|
|
83
|
+
sentText: string | null;
|
|
84
|
+
sentItemId: string | null;
|
|
85
|
+
edited: boolean;
|
|
86
|
+
createdAt: number;
|
|
87
|
+
decidedAt: number | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface PaneSession {
|
|
91
|
+
id: string;
|
|
92
|
+
name: string;
|
|
93
|
+
agentType: string;
|
|
94
|
+
model: string | null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A link as the panes and the native app read it.
|
|
98
|
+
export interface AnalystLinkView extends AnalystLink {
|
|
99
|
+
coder: PaneSession;
|
|
100
|
+
analyst: PaneSession;
|
|
101
|
+
// The coding agent's latest finished turn, to tell a card it has moved on.
|
|
102
|
+
coderLastTurn: string | null;
|
|
103
|
+
recommendations: Recommendation[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class AnalystError extends Error {
|
|
107
|
+
constructor(
|
|
108
|
+
message: string,
|
|
109
|
+
readonly status: 400 | 404 | 409 = 400
|
|
110
|
+
) {
|
|
111
|
+
super(message);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const createLinkSchema = z
|
|
116
|
+
.object({
|
|
117
|
+
coderSessionId: z.string().min(1).optional(),
|
|
118
|
+
newCoder: z
|
|
119
|
+
.object({
|
|
120
|
+
projectId: z.string().min(1),
|
|
121
|
+
agentType: z.string().min(1),
|
|
122
|
+
model: z.string().trim().min(1).optional(),
|
|
123
|
+
prompt: z.string().trim().max(20_000).optional(),
|
|
124
|
+
useWorktree: z.boolean().optional(),
|
|
125
|
+
})
|
|
126
|
+
.optional(),
|
|
127
|
+
// The analyst's model for this pair, "provider/model"; else its role's.
|
|
128
|
+
analystModel: z.string().trim().min(1).max(200).optional(),
|
|
129
|
+
reviewLast: z.boolean().optional(),
|
|
130
|
+
})
|
|
131
|
+
.refine((b) => !!b.coderSessionId !== !!b.newCoder, {
|
|
132
|
+
message: "Give coderSessionId or newCoder, not both",
|
|
133
|
+
});
|
|
134
|
+
export type CreateLinkInput = z.infer<typeof createLinkSchema>;
|
|
135
|
+
|
|
136
|
+
export const patchLinkSchema = z.object({ paused: z.boolean() });
|
|
137
|
+
|
|
138
|
+
export const sendSchema = z.object({
|
|
139
|
+
text: z.string().trim().min(1).max(20_000).optional(),
|
|
140
|
+
});
|
package/lib/bus/condense.ts
CHANGED
|
@@ -102,7 +102,11 @@ function lineOf(i: ChatItem): string | null {
|
|
|
102
102
|
case "compacted":
|
|
103
103
|
return "[conversation compacted]";
|
|
104
104
|
case "turn_end":
|
|
105
|
-
return i.interrupted
|
|
105
|
+
return i.interrupted
|
|
106
|
+
? "[turn stopped]"
|
|
107
|
+
: i.ended
|
|
108
|
+
? `[turn ended early: ${i.ended.reason}]`
|
|
109
|
+
: null;
|
|
106
110
|
default:
|
|
107
111
|
return null;
|
|
108
112
|
}
|
package/lib/bus/index.ts
CHANGED
|
@@ -67,6 +67,9 @@ export interface Peer {
|
|
|
67
67
|
waitedBy: string[];
|
|
68
68
|
// The user's group it sits in, when it is in one.
|
|
69
69
|
group?: string;
|
|
70
|
+
// The agent role it was started in (lib/models/agent-roles), and its model.
|
|
71
|
+
agentRole?: string;
|
|
72
|
+
model?: string;
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
// A chat has no pane: its worker is the state, and it can always take a
|
|
@@ -118,6 +121,8 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
|
|
|
118
121
|
orchestratorOf: orchestratorOf(s),
|
|
119
122
|
waitingOn: badges.get(s.id)?.on.map((w) => w.name) ?? [],
|
|
120
123
|
waitedBy: badges.get(s.id)?.by.map((w) => w.name) ?? [],
|
|
124
|
+
...(s.agent_role && { agentRole: s.agent_role }),
|
|
125
|
+
...(s.agent_role && s.model && { model: s.model }),
|
|
121
126
|
...(s.session_group_id &&
|
|
122
127
|
groups.has(s.session_group_id) && {
|
|
123
128
|
group: groups.get(s.session_group_id),
|
package/lib/bus/read-scope.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { getProject } from "../projects";
|
|
|
4
4
|
import { getWorkspace } from "../workspaces";
|
|
5
5
|
import { previousNames } from "../session-names";
|
|
6
6
|
import { resolveRef, type Candidate } from "./resolve";
|
|
7
|
+
import { resolveRoleAlias, roleAliasOf } from "./role-alias";
|
|
7
8
|
|
|
8
9
|
export type ReadableSession = Session & { project_name: string | null };
|
|
9
10
|
|
|
@@ -79,6 +80,17 @@ export function resolveReadable(callerId: string, ref: string): Readable {
|
|
|
79
80
|
a.updated_at < b.updated_at ? 1 : -1
|
|
80
81
|
);
|
|
81
82
|
const mine = all.filter((s) => inScope(s, scope));
|
|
83
|
+
const alias = roleAliasOf(ref);
|
|
84
|
+
if (alias) {
|
|
85
|
+
const found = resolveRoleAlias(
|
|
86
|
+
alias,
|
|
87
|
+
mine.filter(isLive),
|
|
88
|
+
all.find((s) => s.id === callerId)
|
|
89
|
+
);
|
|
90
|
+
return found.ok
|
|
91
|
+
? { ok: true, session: found.session }
|
|
92
|
+
: { ok: false, external: found.none, error: found.error };
|
|
93
|
+
}
|
|
82
94
|
let none = "";
|
|
83
95
|
for (const group of [mine.filter(isLive), mine.filter((s) => !isLive(s))]) {
|
|
84
96
|
const r = resolveRef(ref, candidates(group));
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "@reviewer", "@developer", "@analyst", "@supervisor": the live session in
|
|
3
|
+
* that agent role. The caller's crew first, then its project; a supervisor
|
|
4
|
+
* nobody started is the workspace's orchestrator. More than one match
|
|
5
|
+
* refuses and lists them, like any other name.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Session } from "../db";
|
|
9
|
+
|
|
10
|
+
type RoleSession = Pick<
|
|
11
|
+
Session,
|
|
12
|
+
| "id"
|
|
13
|
+
| "name"
|
|
14
|
+
| "role"
|
|
15
|
+
| "agent_role"
|
|
16
|
+
| "crew_id"
|
|
17
|
+
| "project_id"
|
|
18
|
+
| "workspace_id"
|
|
19
|
+
>;
|
|
20
|
+
import {
|
|
21
|
+
AGENT_ROLES,
|
|
22
|
+
isAgentRole,
|
|
23
|
+
type AgentRole,
|
|
24
|
+
} from "../models/agent-roles";
|
|
25
|
+
|
|
26
|
+
export type RoleAlias<T extends RoleSession = Session> =
|
|
27
|
+
{ ok: true; session: T } | { ok: false; none: boolean; error: string };
|
|
28
|
+
|
|
29
|
+
export const roleAliasOf = (ref: string): AgentRole | null => {
|
|
30
|
+
const m = /^@([a-z]+)$/i.exec(ref.trim());
|
|
31
|
+
const role = m?.[1].toLowerCase();
|
|
32
|
+
return isAgentRole(role) ? role : null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const label = (s: RoleSession) => `${s.name} (id ${s.id.slice(0, 8)})`;
|
|
36
|
+
|
|
37
|
+
export function resolveRoleAlias<T extends RoleSession>(
|
|
38
|
+
role: AgentRole,
|
|
39
|
+
sessions: T[],
|
|
40
|
+
caller: RoleSession | undefined
|
|
41
|
+
): RoleAlias<T> {
|
|
42
|
+
const others = sessions.filter((s) => s.id !== caller?.id);
|
|
43
|
+
const inRole = others.filter((s) => s.agent_role === role);
|
|
44
|
+
const scopes = [
|
|
45
|
+
caller?.crew_id ? inRole.filter((s) => s.crew_id === caller.crew_id) : [],
|
|
46
|
+
caller?.project_id && caller.project_id !== "uncategorized"
|
|
47
|
+
? inRole.filter((s) => s.project_id === caller.project_id)
|
|
48
|
+
: [],
|
|
49
|
+
];
|
|
50
|
+
for (const found of scopes) {
|
|
51
|
+
if (found.length === 1) return { ok: true, session: found[0] };
|
|
52
|
+
if (found.length > 1)
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
none: false,
|
|
56
|
+
error: `"@${role}" matches ${found.length} sessions: ${found.map(label).join(", ")}. Use the name or the id`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (role === "supervisor" && caller?.workspace_id) {
|
|
60
|
+
const orchestrator = others.find(
|
|
61
|
+
(s) => s.role === "orchestrator" && s.workspace_id === caller.workspace_id
|
|
62
|
+
);
|
|
63
|
+
if (orchestrator) return { ok: true, session: orchestrator };
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
none: true,
|
|
68
|
+
error: `No ${role} is running in your crew or project. Roles: ${AGENT_ROLES.map((r) => `@${r}`).join(", ")}. Run: aos peers`,
|
|
69
|
+
};
|
|
70
|
+
}
|
package/lib/bus/targets.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { previousNames } from "../session-names";
|
|
|
9
9
|
import { sessionWorkspace } from "../schedules/store";
|
|
10
10
|
import { heldTarget } from "./held-reply";
|
|
11
11
|
import { resolveRef, type Candidate } from "./resolve";
|
|
12
|
+
import { resolveRoleAlias, roleAliasOf } from "./role-alias";
|
|
12
13
|
|
|
13
14
|
export function liveSessions(): Session[] {
|
|
14
15
|
return db
|
|
@@ -86,6 +87,8 @@ export function tryResolveTarget(
|
|
|
86
87
|
| { ok: true; session: Session; note?: string }
|
|
87
88
|
| { ok: false; none: boolean; error: string } {
|
|
88
89
|
const sessions = liveSessions();
|
|
90
|
+
const alias = roleAliasOf(ref);
|
|
91
|
+
if (alias) return resolveRoleAlias(alias, sessions, sessionById(callerId));
|
|
89
92
|
const own = ownOrchestrator(ref, sessions, callerId);
|
|
90
93
|
if (own) return { ok: true, session: own };
|
|
91
94
|
const r = resolveRef(ref, candidates(sessions));
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from "crypto";
|
|
|
2
2
|
import type { DriverEvent, UsageTotals } from "../events";
|
|
3
3
|
import { openCodeTool } from "./opencode-rules";
|
|
4
4
|
import { TurnItems } from "./turn-items";
|
|
5
|
+
import { logTurnEnd, turnOutcome } from "./turn-outcome";
|
|
5
6
|
|
|
6
7
|
type P = Record<string, unknown>;
|
|
7
8
|
const str = (v: unknown) => (typeof v === "string" ? v : "");
|
|
@@ -26,6 +27,15 @@ export class OpenCodeMapper {
|
|
|
26
27
|
private current = new Set<string>();
|
|
27
28
|
private past = new Set<string>();
|
|
28
29
|
private error: string | null = null;
|
|
30
|
+
// The turn's last assistant message: its finish reason, text and calls.
|
|
31
|
+
private last = {
|
|
32
|
+
id: "",
|
|
33
|
+
finish: "",
|
|
34
|
+
provider: "",
|
|
35
|
+
model: "",
|
|
36
|
+
text: new Map<string, string>(),
|
|
37
|
+
calls: new Set<string>(),
|
|
38
|
+
};
|
|
29
39
|
private aborted = false;
|
|
30
40
|
// Idle from before the prompt is stale until the session goes busy.
|
|
31
41
|
private admitted = false;
|
|
@@ -98,6 +108,18 @@ export class OpenCodeMapper {
|
|
|
98
108
|
this.current.add(id);
|
|
99
109
|
this.roles.set(id, str(info.role));
|
|
100
110
|
if (info.role !== "assistant") return;
|
|
111
|
+
if (this.last.id !== id)
|
|
112
|
+
this.last = {
|
|
113
|
+
id,
|
|
114
|
+
finish: "",
|
|
115
|
+
provider: "",
|
|
116
|
+
model: "",
|
|
117
|
+
text: new Map(),
|
|
118
|
+
calls: new Set(),
|
|
119
|
+
};
|
|
120
|
+
this.last.finish = str(info.finish) || this.last.finish;
|
|
121
|
+
this.last.provider = str(info.providerID) || this.last.provider;
|
|
122
|
+
this.last.model = str(info.modelID) || this.last.model;
|
|
101
123
|
if (info.error) this.failed(info.error as P);
|
|
102
124
|
if (!(info.time as P)?.completed || this.counted.has(id)) return;
|
|
103
125
|
this.counted.add(id);
|
|
@@ -125,6 +147,8 @@ export class OpenCodeMapper {
|
|
|
125
147
|
const seen = this.parts.get(id) ?? { kind, shown: 0 };
|
|
126
148
|
this.parts.set(id, seen);
|
|
127
149
|
const text = str(part.text);
|
|
150
|
+
if (kind === "assistant" && this.last.id === str(part.messageID))
|
|
151
|
+
this.last.text.set(id, text);
|
|
128
152
|
if (text.length > seen.shown) {
|
|
129
153
|
this.items.delta(id, kind, text.slice(seen.shown));
|
|
130
154
|
seen.shown = text.length;
|
|
@@ -132,7 +156,10 @@ export class OpenCodeMapper {
|
|
|
132
156
|
if ((part.time as P)?.end) this.items.finishText(id, kind, text);
|
|
133
157
|
return;
|
|
134
158
|
}
|
|
135
|
-
if (part.type === "tool")
|
|
159
|
+
if (part.type === "tool") {
|
|
160
|
+
if (this.last.id === str(part.messageID)) this.last.calls.add(id);
|
|
161
|
+
this.tool(id, part);
|
|
162
|
+
}
|
|
136
163
|
}
|
|
137
164
|
|
|
138
165
|
private delta(p: P): void {
|
|
@@ -140,6 +167,11 @@ export class OpenCodeMapper {
|
|
|
140
167
|
const seen = this.parts.get(str(p.partID));
|
|
141
168
|
if (!seen || p.field !== "text") return;
|
|
142
169
|
const text = str(p.delta);
|
|
170
|
+
if (seen.kind === "assistant" && this.last.text.has(str(p.partID)))
|
|
171
|
+
this.last.text.set(
|
|
172
|
+
str(p.partID),
|
|
173
|
+
this.last.text.get(str(p.partID)) + text
|
|
174
|
+
);
|
|
143
175
|
seen.shown += text.length;
|
|
144
176
|
this.items.delta(str(p.partID), seen.kind, text);
|
|
145
177
|
}
|
|
@@ -168,11 +200,33 @@ export class OpenCodeMapper {
|
|
|
168
200
|
private idle(): void {
|
|
169
201
|
if (!this.admitted || !this.items.inTurn) return;
|
|
170
202
|
this.emit({ type: "usage", totals: { ...this.totals } });
|
|
203
|
+
const text = [...this.last.text.values()].join("\n");
|
|
204
|
+
const ended =
|
|
205
|
+
this.aborted || !this.last.id
|
|
206
|
+
? undefined
|
|
207
|
+
: turnOutcome({
|
|
208
|
+
finishReason: this.last.finish,
|
|
209
|
+
text,
|
|
210
|
+
toolCalls: this.last.calls.size,
|
|
211
|
+
error: this.error ?? undefined,
|
|
212
|
+
});
|
|
213
|
+
if (ended)
|
|
214
|
+
logTurnEnd({
|
|
215
|
+
driver: "opencode",
|
|
216
|
+
provider: this.last.provider,
|
|
217
|
+
model: this.last.model,
|
|
218
|
+
finishReason: this.last.finish,
|
|
219
|
+
reason: ended.reason,
|
|
220
|
+
text,
|
|
221
|
+
});
|
|
171
222
|
this.items.endTurn({
|
|
172
223
|
interrupted: this.aborted,
|
|
173
224
|
error: this.error ?? undefined,
|
|
225
|
+
finishReason: this.last.finish || undefined,
|
|
226
|
+
ended,
|
|
174
227
|
});
|
|
175
228
|
this.error = null;
|
|
229
|
+
this.last.id = "";
|
|
176
230
|
this.aborted = false;
|
|
177
231
|
this.admitted = false;
|
|
178
232
|
for (const id of this.current) this.past.add(id);
|
|
@@ -74,11 +74,14 @@ function ensureDir(): void {
|
|
|
74
74
|
fs.chmodSync(DIR, 0o700);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
export function writeExtension(
|
|
77
|
+
export function writeExtension(
|
|
78
|
+
name = "agentos-approvals.mjs",
|
|
79
|
+
source = EXTENSION
|
|
80
|
+
): string {
|
|
78
81
|
ensureDir();
|
|
79
|
-
const file = path.join(DIR,
|
|
80
|
-
if (!fs.existsSync(file) || fs.readFileSync(file, "utf8") !==
|
|
81
|
-
fs.writeFileSync(file,
|
|
82
|
+
const file = path.join(DIR, name);
|
|
83
|
+
if (!fs.existsSync(file) || fs.readFileSync(file, "utf8") !== source)
|
|
84
|
+
fs.writeFileSync(file, source);
|
|
82
85
|
return file;
|
|
83
86
|
}
|
|
84
87
|
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from "crypto";
|
|
|
2
2
|
import type { DriverEvent, UsageTotals } from "../events";
|
|
3
3
|
import { toolDiff, toolTitle } from "../tools";
|
|
4
4
|
import { TurnItems, type ToolStart } from "./turn-items";
|
|
5
|
+
import { logTurnEnd, turnOutcome } from "./turn-outcome";
|
|
5
6
|
|
|
6
7
|
type P = Record<string, unknown>;
|
|
7
8
|
const str = (v: unknown) => (typeof v === "string" ? v : "");
|
|
@@ -50,6 +51,9 @@ export class PiMapper {
|
|
|
50
51
|
private message = 0;
|
|
51
52
|
private stopReason = "";
|
|
52
53
|
private error = "";
|
|
54
|
+
// The last answer's text, its tool calls and who gave it, for the notice
|
|
55
|
+
// when a turn ends without doing anything.
|
|
56
|
+
private last = { text: "", calls: 0, provider: "", model: "", api: "" };
|
|
53
57
|
totals: UsageTotals = {
|
|
54
58
|
costUsd: 0,
|
|
55
59
|
inputTokens: 0,
|
|
@@ -71,6 +75,7 @@ export class PiMapper {
|
|
|
71
75
|
switch (e.type) {
|
|
72
76
|
case "agent_start":
|
|
73
77
|
this.stopReason = this.error = "";
|
|
78
|
+
this.last = { text: "", calls: 0, provider: "", model: "", api: "" };
|
|
74
79
|
return this.items.startTurn();
|
|
75
80
|
case "message_start":
|
|
76
81
|
if ((e.message as P)?.role === "assistant") this.message++;
|
|
@@ -136,6 +141,22 @@ export class PiMapper {
|
|
|
136
141
|
this.stopReason = str(m.stopReason);
|
|
137
142
|
if (this.stopReason === "error")
|
|
138
143
|
this.error = str(m.errorMessage) || "Pi failed";
|
|
144
|
+
const content = Array.isArray(m.content) ? (m.content as P[]) : [];
|
|
145
|
+
this.last = {
|
|
146
|
+
text: content
|
|
147
|
+
.filter((c) => c.type === "text")
|
|
148
|
+
.map((c) => str(c.text))
|
|
149
|
+
.join("\n"),
|
|
150
|
+
calls: content.filter((c) => c.type === "toolCall").length,
|
|
151
|
+
provider: str(m.provider),
|
|
152
|
+
model: str(m.model),
|
|
153
|
+
api: str(m.api),
|
|
154
|
+
};
|
|
155
|
+
// Calls the text-tools extension made out of the reply's markup: the
|
|
156
|
+
// text that streamed still has it.
|
|
157
|
+
const fixed = m.aosRecovered as P | undefined;
|
|
158
|
+
if (fixed)
|
|
159
|
+
this.items.retext(`${this.message}:${fixed.index}`, str(fixed.text));
|
|
139
160
|
const u = (m.usage ?? {}) as P;
|
|
140
161
|
const n = (k: string) => Number(u[k]) || 0;
|
|
141
162
|
this.totals = {
|
|
@@ -151,9 +172,26 @@ export class PiMapper {
|
|
|
151
172
|
endTurn(): void {
|
|
152
173
|
if (!this.items.inTurn) return;
|
|
153
174
|
this.emit({ type: "usage", totals: { ...this.totals } });
|
|
175
|
+
const ended = this.aborted
|
|
176
|
+
? undefined
|
|
177
|
+
: turnOutcome({
|
|
178
|
+
finishReason: this.stopReason,
|
|
179
|
+
text: this.last.text,
|
|
180
|
+
toolCalls: this.last.calls,
|
|
181
|
+
error: this.error || undefined,
|
|
182
|
+
});
|
|
183
|
+
if (ended)
|
|
184
|
+
logTurnEnd({
|
|
185
|
+
driver: "pi",
|
|
186
|
+
...this.last,
|
|
187
|
+
finishReason: this.stopReason,
|
|
188
|
+
reason: ended.reason,
|
|
189
|
+
});
|
|
154
190
|
this.items.endTurn({
|
|
155
191
|
interrupted: this.aborted,
|
|
156
192
|
error: this.error || undefined,
|
|
193
|
+
finishReason: this.stopReason || undefined,
|
|
194
|
+
ended,
|
|
157
195
|
});
|
|
158
196
|
}
|
|
159
197
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Local models served without tool-call parsing write their calls as text:
|
|
2
|
+
// <tool_call>{"name":…,"arguments":…}</tool_call> (Hermes, Qwen) or
|
|
3
|
+
// <tool_call><function=bash><parameter=command>…</parameter></function>
|
|
4
|
+
// </tool_call> (Qwen3-Coder). Pi only runs structured calls, so this Pi
|
|
5
|
+
// extension turns those trailing blocks in a finished reply into real tool
|
|
6
|
+
// calls. They are ordinary tool calls from then on: Pi's tool_call event
|
|
7
|
+
// fires for them, so the approvals extension (pi-approvals) asks, or lets
|
|
8
|
+
// them through, exactly as for a structured call. Exported for its test.
|
|
9
|
+
export const TEXT_TOOLS_EXTENSION = String.raw`const OPEN = "<tool_call>";
|
|
10
|
+
const CLOSE = "</tool_call>";
|
|
11
|
+
let count = 0;
|
|
12
|
+
const FENCE = String.fromCharCode(96).repeat(3);
|
|
13
|
+
const inFence = (text, at) => text.slice(0, at).split(FENCE).length % 2 === 0;
|
|
14
|
+
const trimOne = (v) => v.replace(/^\r?\n/, "").replace(/\r?\n$/, "");
|
|
15
|
+
|
|
16
|
+
function parseBody(body, schemas) {
|
|
17
|
+
const b = body.trim();
|
|
18
|
+
if (b.startsWith("{")) {
|
|
19
|
+
let o;
|
|
20
|
+
try {
|
|
21
|
+
o = JSON.parse(b);
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
let args = o.arguments ?? o.parameters ?? {};
|
|
26
|
+
if (typeof args === "string") {
|
|
27
|
+
try {
|
|
28
|
+
args = JSON.parse(args);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (typeof o.name !== "string" || !args || typeof args !== "object" || Array.isArray(args)) return null;
|
|
34
|
+
return { name: o.name, arguments: args };
|
|
35
|
+
}
|
|
36
|
+
const f = /^<function=([^>\s]+)>([\s\S]*?)(?:<\/function>)?$/.exec(b);
|
|
37
|
+
if (!f) return null;
|
|
38
|
+
const props = schemas.get(f[1])?.properties ?? {};
|
|
39
|
+
const args = {};
|
|
40
|
+
for (const m of f[2].matchAll(/<parameter=([^>\s]+)>([\s\S]*?)<\/parameter>/g)) {
|
|
41
|
+
const raw = trimOne(m[2]);
|
|
42
|
+
let value = raw;
|
|
43
|
+
if (props[m[1]]?.type && props[m[1]].type !== "string") {
|
|
44
|
+
try {
|
|
45
|
+
value = JSON.parse(raw);
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
args[m[1]] = value;
|
|
49
|
+
}
|
|
50
|
+
return { name: f[1], arguments: args };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The calls in a tail that is nothing but call blocks, or null.
|
|
54
|
+
function parseTail(tail, schemas) {
|
|
55
|
+
const calls = [];
|
|
56
|
+
let pos = 0;
|
|
57
|
+
for (;;) {
|
|
58
|
+
while (pos < tail.length && /\s/.test(tail[pos])) pos++;
|
|
59
|
+
if (pos >= tail.length) break;
|
|
60
|
+
if (!tail.startsWith(OPEN, pos)) return null;
|
|
61
|
+
const start = pos + OPEN.length;
|
|
62
|
+
const end = tail.indexOf(CLOSE, start);
|
|
63
|
+
const call = parseBody(end < 0 ? tail.slice(start) : tail.slice(start, end), schemas);
|
|
64
|
+
if (!call || !schemas.has(call.name)) return null;
|
|
65
|
+
calls.push(call);
|
|
66
|
+
if (end < 0) break;
|
|
67
|
+
pos = end + CLOSE.length;
|
|
68
|
+
}
|
|
69
|
+
return calls.length ? calls : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export default function (pi) {
|
|
73
|
+
pi.on("message_end", async (event) => {
|
|
74
|
+
const m = event.message;
|
|
75
|
+
if (m?.role !== "assistant" || m.stopReason !== "stop" || !Array.isArray(m.content)) return;
|
|
76
|
+
if (m.content.some((c) => c.type === "toolCall")) return;
|
|
77
|
+
let at = -1;
|
|
78
|
+
for (let i = m.content.length - 1; i >= 0; i--) {
|
|
79
|
+
if (m.content[i].type === "text") {
|
|
80
|
+
at = i;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (at < 0 || !m.content[at].text.includes(OPEN)) return;
|
|
85
|
+
const text = m.content[at].text;
|
|
86
|
+
const schemas = new Map();
|
|
87
|
+
const active = new Set(pi.getActiveTools());
|
|
88
|
+
for (const t of pi.getAllTools()) if (active.has(t.name)) schemas.set(t.name, t.parameters ?? {});
|
|
89
|
+
for (let from = text.indexOf(OPEN); from >= 0; from = text.indexOf(OPEN, from + 1)) {
|
|
90
|
+
if (inFence(text, from)) continue;
|
|
91
|
+
const calls = parseTail(text.slice(from), schemas);
|
|
92
|
+
if (!calls) continue;
|
|
93
|
+
const rest = text.slice(0, from).trimEnd();
|
|
94
|
+
const content = m.content.slice();
|
|
95
|
+
content[at] = { ...content[at], text: rest };
|
|
96
|
+
if (!rest) content.splice(at, 1);
|
|
97
|
+
for (const c of calls) {
|
|
98
|
+
content.push({ type: "toolCall", id: "aostext_" + ++count + "_" + Math.random().toString(36).slice(2, 8), name: c.name, arguments: c.arguments });
|
|
99
|
+
}
|
|
100
|
+
return { message: { ...m, content, stopReason: "toolUse", aosRecovered: { index: at, text: rest } } };
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
`;
|
package/lib/chat/drivers/pi.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { ChatImage, DriverEvent } from "../events";
|
|
|
4
4
|
import { InputQueue } from "../queue";
|
|
5
5
|
import { AGENT_DEFAULT_MODEL } from "../../providers/registry";
|
|
6
6
|
import { PiAccess, PiDialogs, writeExtension } from "./pi-approvals";
|
|
7
|
+
import { TEXT_TOOLS_EXTENSION } from "./pi-text-tools";
|
|
7
8
|
import { PiMapper } from "./pi-mapper";
|
|
8
9
|
import { PiRpc } from "./pi-rpc";
|
|
9
10
|
|
|
@@ -11,8 +12,8 @@ type P = Record<string, unknown>;
|
|
|
11
12
|
|
|
12
13
|
const SETTLE_TRIES = 5;
|
|
13
14
|
|
|
14
|
-
export function piArgs(o: ChatStartOptions,
|
|
15
|
-
const args = ["-e",
|
|
15
|
+
export function piArgs(o: ChatStartOptions, ...extensions: string[]): string[] {
|
|
16
|
+
const args = extensions.flatMap((e) => ["-e", e]);
|
|
16
17
|
// A path, so a conversation started elsewhere never makes Pi ask about it.
|
|
17
18
|
if (o.resumeId) args.push("--session", o.resumeId);
|
|
18
19
|
if (o.model && o.model !== AGENT_DEFAULT_MODEL) args.push("--model", o.model);
|
|
@@ -79,7 +80,11 @@ export const piDriver: ChatDriver = {
|
|
|
79
80
|
// Sent now: goes once the stopped run has ended.
|
|
80
81
|
const held: { text: string; images?: ChatImage[] }[] = [];
|
|
81
82
|
const rpc: PiRpc = new PiRpc(
|
|
82
|
-
piArgs(
|
|
83
|
+
piArgs(
|
|
84
|
+
options,
|
|
85
|
+
writeExtension(),
|
|
86
|
+
writeExtension("agentos-text-tools.mjs", TEXT_TOOLS_EXTENSION)
|
|
87
|
+
),
|
|
83
88
|
{
|
|
84
89
|
cwd: options.cwd,
|
|
85
90
|
env: {
|