paseo-form-render 0.0.0-stage → 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 +21 -0
- package/README.md +113 -2
- package/client/container.tsx +286 -0
- package/client/host-markdown.tsx +24 -0
- package/client/markdown.tsx +470 -0
- package/client/project-file-modal.tsx +687 -0
- package/client/react-native-dataset.d.ts +10 -0
- package/client/renderer.tsx +1053 -0
- package/client/rpc-bridge.tsx +27 -0
- package/client/settings-screen.tsx +168 -0
- package/index.client.tsx +102 -0
- package/index.server.ts +38 -0
- package/package.json +50 -4
- package/paseo-plugin.json +8 -0
- package/server/auto-fix.ts +199 -0
- package/server/demo.ts +20 -0
- package/server/file-picker.ts +253 -0
- package/server/settings.ts +26 -0
- package/shared/demo.ts +96 -0
- package/shared/form-errors.ts +390 -0
- package/shared/form-fix.ts +77 -0
- package/shared/form-helpers.ts +214 -0
- package/shared/parser.ts +119 -0
- package/shared/pickers.ts +50 -0
- package/shared/prompt.ts +96 -0
- package/shared/schema.ts +330 -0
- package/shared/settings.ts +18 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PluginRpcContract } from "@getpaseo/plugin";
|
|
2
|
+
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
3
|
+
import { useRpc } from "@getpaseo/plugin/client";
|
|
4
|
+
import React, { createContext, useContext } from "react";
|
|
5
|
+
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
|
|
6
|
+
|
|
7
|
+
export type PfrRpcFn = PluginClientContext["rpc"];
|
|
8
|
+
|
|
9
|
+
const PfrRpcContext = createContext<PfrRpcFn | null>(null);
|
|
10
|
+
|
|
11
|
+
export const PfrRpcProvider: React.FC<{ rpc?: PfrRpcFn; children: React.ReactNode }> = ({
|
|
12
|
+
rpc,
|
|
13
|
+
children,
|
|
14
|
+
}) => {
|
|
15
|
+
if (!rpc) return <>{children}</>;
|
|
16
|
+
return <PfrRpcContext.Provider value={rpc}>{children}</PfrRpcContext.Provider>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function usePfrRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
|
20
|
+
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
|
21
|
+
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>> {
|
|
22
|
+
const customRpc = useContext(PfrRpcContext);
|
|
23
|
+
if (customRpc) {
|
|
24
|
+
return (input: ZodInput<InputSchema>) => customRpc(contract, input);
|
|
25
|
+
}
|
|
26
|
+
return useRpc(contract);
|
|
27
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { useSettings } from "@getpaseo/plugin/client";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { ActivityIndicator, Pressable, StyleSheet, Switch, Text, View } from "react-native";
|
|
5
|
+
import { MAX_AUTO_FIX_RETRIES, pfrSettings } from "../shared/settings";
|
|
6
|
+
|
|
7
|
+
export const PfrSettingsScreen: React.FC<PluginSurfaceProps> = ({ theme }) => {
|
|
8
|
+
const settings = useSettings(pfrSettings);
|
|
9
|
+
const { colors } = theme;
|
|
10
|
+
|
|
11
|
+
if (settings.status === "loading") {
|
|
12
|
+
return (
|
|
13
|
+
<View style={styles.container}>
|
|
14
|
+
<ActivityIndicator color={colors.accent} />
|
|
15
|
+
</View>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (settings.status === "error" || settings.status === "invalid") {
|
|
20
|
+
return (
|
|
21
|
+
<View style={styles.container}>
|
|
22
|
+
<Text style={[styles.errorText, { color: colors.statusDanger }]}>
|
|
23
|
+
Could not load settings: {settings.error}
|
|
24
|
+
</Text>
|
|
25
|
+
<Pressable
|
|
26
|
+
style={[styles.stepButton, styles.retryButton, { borderColor: colors.border, backgroundColor: colors.surface2 }]}
|
|
27
|
+
onPress={() => void settings.reload()}
|
|
28
|
+
>
|
|
29
|
+
<Text style={[styles.stepText, { color: colors.foreground }]}>Retry</Text>
|
|
30
|
+
</Pressable>
|
|
31
|
+
{settings.status === "invalid" && (
|
|
32
|
+
<Pressable
|
|
33
|
+
style={[styles.stepButton, styles.retryButton, { borderColor: colors.border, backgroundColor: colors.surface2 }]}
|
|
34
|
+
onPress={() => void settings.reset()}
|
|
35
|
+
>
|
|
36
|
+
<Text style={[styles.stepText, { color: colors.foreground }]}>Reset to defaults</Text>
|
|
37
|
+
</Pressable>
|
|
38
|
+
)}
|
|
39
|
+
</View>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const value = settings.values.autoFixMaxRetries;
|
|
44
|
+
const change = (next: number) => {
|
|
45
|
+
if (next < 0 || next > MAX_AUTO_FIX_RETRIES || settings.saving) return;
|
|
46
|
+
void settings.save({ ...settings.values, autoFixMaxRetries: next }, settings.revision);
|
|
47
|
+
};
|
|
48
|
+
const stepStyle = (disabled: boolean) => [
|
|
49
|
+
styles.stepButton,
|
|
50
|
+
{ borderColor: colors.border, backgroundColor: colors.surface2 },
|
|
51
|
+
disabled && { opacity: 0.4 },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<View style={styles.container}>
|
|
56
|
+
<Text style={[styles.title, { color: colors.foreground }]}>Automatic form fixes</Text>
|
|
57
|
+
<Text style={[styles.description, { color: colors.foregroundMuted }]}>
|
|
58
|
+
When an agent replies with a pfr-form block that fails validation, ask it to re-send a corrected block
|
|
59
|
+
automatically. This sets how many times in a row the agent is asked; 0 turns automatic fixes off. The
|
|
60
|
+
"Ask agent to fix" button on an error box always works.
|
|
61
|
+
</Text>
|
|
62
|
+
|
|
63
|
+
<View style={styles.row}>
|
|
64
|
+
<Text style={[styles.label, { color: colors.foreground }]}>Maximum retries</Text>
|
|
65
|
+
<View style={styles.stepper}>
|
|
66
|
+
<Pressable
|
|
67
|
+
accessibilityLabel="Decrease retries"
|
|
68
|
+
disabled={value <= 0 || settings.saving}
|
|
69
|
+
style={stepStyle(value <= 0 || settings.saving)}
|
|
70
|
+
onPress={() => change(value - 1)}
|
|
71
|
+
>
|
|
72
|
+
<Text style={[styles.stepText, { color: colors.foreground }]}>−</Text>
|
|
73
|
+
</Pressable>
|
|
74
|
+
<Text style={[styles.value, { color: colors.foreground }]}>{value}</Text>
|
|
75
|
+
<Pressable
|
|
76
|
+
accessibilityLabel="Increase retries"
|
|
77
|
+
disabled={value >= MAX_AUTO_FIX_RETRIES || settings.saving}
|
|
78
|
+
style={stepStyle(value >= MAX_AUTO_FIX_RETRIES || settings.saving)}
|
|
79
|
+
onPress={() => change(value + 1)}
|
|
80
|
+
>
|
|
81
|
+
<Text style={[styles.stepText, { color: colors.foreground }]}>+</Text>
|
|
82
|
+
</Pressable>
|
|
83
|
+
</View>
|
|
84
|
+
</View>
|
|
85
|
+
|
|
86
|
+
<Text style={[styles.hint, { color: colors.foregroundMuted }]}>
|
|
87
|
+
{value === 0 ? "Automatic fixes are off." : `Up to ${value} automatic ${value === 1 ? "request" : "requests"} per reply.`}
|
|
88
|
+
{settings.saving ? " Saving…" : ""}
|
|
89
|
+
</Text>
|
|
90
|
+
|
|
91
|
+
<Text style={[styles.title, { color: colors.foreground }]}>Prefer forms</Text>
|
|
92
|
+
<Text style={[styles.description, { color: colors.foregroundMuted }]}>
|
|
93
|
+
By default the agent is only told it can use pfr-form. Turn this on to require it to ask for choices,
|
|
94
|
+
parameters, and confirmations with a form instead of plain text. Applies to agents created after the change.
|
|
95
|
+
</Text>
|
|
96
|
+
<View style={styles.row}>
|
|
97
|
+
<Text style={[styles.label, { color: colors.foreground }]}>Require forms for user input</Text>
|
|
98
|
+
<Switch
|
|
99
|
+
accessibilityLabel="Require forms for user input"
|
|
100
|
+
value={settings.values.preferForms}
|
|
101
|
+
disabled={settings.saving}
|
|
102
|
+
onValueChange={(next) => void settings.save({ ...settings.values, preferForms: next }, settings.revision)}
|
|
103
|
+
/>
|
|
104
|
+
</View>
|
|
105
|
+
{settings.saveError && (
|
|
106
|
+
<Text style={[styles.errorText, { color: colors.statusDanger }]}>Could not save: {settings.saveError}</Text>
|
|
107
|
+
)}
|
|
108
|
+
</View>
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const styles = StyleSheet.create({
|
|
113
|
+
container: {
|
|
114
|
+
padding: 16,
|
|
115
|
+
gap: 12,
|
|
116
|
+
},
|
|
117
|
+
title: {
|
|
118
|
+
fontSize: 16,
|
|
119
|
+
fontWeight: "600",
|
|
120
|
+
},
|
|
121
|
+
description: {
|
|
122
|
+
fontSize: 13,
|
|
123
|
+
lineHeight: 18,
|
|
124
|
+
},
|
|
125
|
+
row: {
|
|
126
|
+
flexDirection: "row",
|
|
127
|
+
alignItems: "center",
|
|
128
|
+
justifyContent: "space-between",
|
|
129
|
+
},
|
|
130
|
+
label: {
|
|
131
|
+
fontSize: 14,
|
|
132
|
+
fontWeight: "500",
|
|
133
|
+
},
|
|
134
|
+
stepper: {
|
|
135
|
+
flexDirection: "row",
|
|
136
|
+
alignItems: "center",
|
|
137
|
+
gap: 12,
|
|
138
|
+
},
|
|
139
|
+
stepButton: {
|
|
140
|
+
width: 36,
|
|
141
|
+
height: 36,
|
|
142
|
+
borderRadius: 6,
|
|
143
|
+
borderWidth: 1,
|
|
144
|
+
alignItems: "center",
|
|
145
|
+
justifyContent: "center",
|
|
146
|
+
},
|
|
147
|
+
retryButton: {
|
|
148
|
+
width: undefined,
|
|
149
|
+
alignSelf: "flex-start",
|
|
150
|
+
paddingHorizontal: 12,
|
|
151
|
+
},
|
|
152
|
+
stepText: {
|
|
153
|
+
fontSize: 16,
|
|
154
|
+
fontWeight: "600",
|
|
155
|
+
},
|
|
156
|
+
value: {
|
|
157
|
+
fontSize: 16,
|
|
158
|
+
fontWeight: "600",
|
|
159
|
+
minWidth: 20,
|
|
160
|
+
textAlign: "center",
|
|
161
|
+
},
|
|
162
|
+
hint: {
|
|
163
|
+
fontSize: 12,
|
|
164
|
+
},
|
|
165
|
+
errorText: {
|
|
166
|
+
fontSize: 12,
|
|
167
|
+
},
|
|
168
|
+
});
|
package/index.client.tsx
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { PluginClientContext, PluginTimelineItemProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { usePaseo } from "@getpaseo/plugin/client";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Platform } from "react-native";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { hasFormBlock } from "./shared/parser";
|
|
7
|
+
import { MessageContainer } from "./client/container";
|
|
8
|
+
import { PfrSettingsScreen } from "./client/settings-screen";
|
|
9
|
+
import { showDemoForm } from "./shared/demo";
|
|
10
|
+
import { PFR_PROMPT_INSTRUCTION } from "./shared/prompt";
|
|
11
|
+
|
|
12
|
+
const timelineKind = "pfr-form";
|
|
13
|
+
const timelineDataSchema = z.object({
|
|
14
|
+
text: z.string(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export default function contribute(client: PluginClientContext) {
|
|
18
|
+
function TimelineMessageView({
|
|
19
|
+
item,
|
|
20
|
+
agentId,
|
|
21
|
+
theme,
|
|
22
|
+
}: PluginTimelineItemProps<z.output<typeof timelineDataSchema>>) {
|
|
23
|
+
const paseo = usePaseo();
|
|
24
|
+
|
|
25
|
+
const handleSubmit = async (formattedMessage: string) => {
|
|
26
|
+
await paseo.agents.ref(agentId).send(formattedMessage);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<MessageContainer
|
|
31
|
+
text={item.data.text}
|
|
32
|
+
theme={theme}
|
|
33
|
+
agentId={agentId}
|
|
34
|
+
rpc={client.rpc}
|
|
35
|
+
onSubmit={handleSubmit}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
// Transform assistant messages that include pfr-form fences across all platforms (web & mobile).
|
|
40
|
+
client.addTimelineTransformer({
|
|
41
|
+
id: "pfr-transformer",
|
|
42
|
+
query: { itemType: "assistant_message" },
|
|
43
|
+
transform({ item }) {
|
|
44
|
+
if (!hasFormBlock(item.text)) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
items: [
|
|
49
|
+
{
|
|
50
|
+
type: "plugin",
|
|
51
|
+
kind: timelineKind,
|
|
52
|
+
version: 1,
|
|
53
|
+
data: {
|
|
54
|
+
text: item.text,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Render plugin timeline items
|
|
63
|
+
client.addTimelineRenderer({
|
|
64
|
+
kind: timelineKind,
|
|
65
|
+
version: 1,
|
|
66
|
+
schema: timelineDataSchema,
|
|
67
|
+
Component: TimelineMessageView,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Host-level settings, including the automatic fix retry limit
|
|
71
|
+
client.addSettingsScreen({
|
|
72
|
+
id: "paseo-form-render-settings",
|
|
73
|
+
title: "Paseo Form Render",
|
|
74
|
+
icon: "Settings",
|
|
75
|
+
Component: PfrSettingsScreen,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Slash command to enable pfr-form in an existing agent session
|
|
79
|
+
client.addSlashCommand({
|
|
80
|
+
name: "form",
|
|
81
|
+
description: "Instruct the agent to use pfr-form protocol",
|
|
82
|
+
argumentHint: "[task]",
|
|
83
|
+
context: "agent",
|
|
84
|
+
async onSubmit({ args, paseo, agent }) {
|
|
85
|
+
const task = args || "Please enable the pfr-form protocol and present interactive forms when applicable.";
|
|
86
|
+
await paseo.agents.ref(agent.id).send(`${PFR_PROMPT_INSTRUCTION}\n\n${task}`);
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Slash command to show a sample form
|
|
91
|
+
client.addSlashCommand({
|
|
92
|
+
name: "form-demo",
|
|
93
|
+
description: "Show a demo interactive form",
|
|
94
|
+
argumentHint: "",
|
|
95
|
+
context: "agent",
|
|
96
|
+
async onSubmit({ rpc, agent }) {
|
|
97
|
+
await rpc(showDemoForm, { agentId: agent.id });
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return () => {};
|
|
102
|
+
}
|
package/index.server.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import { registerAutoFix } from "./server/auto-fix";
|
|
3
|
+
import { appendDemoForm } from "./server/demo";
|
|
4
|
+
import { readPfrSettings, registerPfrSettings } from "./server/settings";
|
|
5
|
+
import { handleListProjectFiles, handleCreateDirectory } from "./server/file-picker";
|
|
6
|
+
import { showDemoForm } from "./shared/demo";
|
|
7
|
+
import { listProjectFilesRpc, createDirectoryRpc } from "./shared/pickers";
|
|
8
|
+
import { buildPromptInstruction } from "./shared/prompt";
|
|
9
|
+
|
|
10
|
+
export default function contribute(server: PluginServerContext) {
|
|
11
|
+
const settings = registerPfrSettings(server);
|
|
12
|
+
|
|
13
|
+
// Inject instructions when a new agent is created
|
|
14
|
+
server.before("agent.create", async ({ request }) => {
|
|
15
|
+
const previous = request.config.systemPrompt ?? "";
|
|
16
|
+
if (previous.includes("[paseo-form-render]")) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const { preferForms } = await readPfrSettings(settings);
|
|
20
|
+
return {
|
|
21
|
+
...request,
|
|
22
|
+
config: {
|
|
23
|
+
...request.config,
|
|
24
|
+
systemPrompt: [previous, buildPromptInstruction({ preferForms })].filter(Boolean).join("\n\n"),
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
server.handle(showDemoForm, appendDemoForm);
|
|
30
|
+
server.handle(listProjectFilesRpc, handleListProjectFiles);
|
|
31
|
+
server.handle(createDirectoryRpc, handleCreateDirectory);
|
|
32
|
+
|
|
33
|
+
const stopAutoFix = registerAutoFix(server, settings);
|
|
34
|
+
|
|
35
|
+
return () => {
|
|
36
|
+
stopAutoFix();
|
|
37
|
+
};
|
|
38
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paseo-form-render",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Interactive forms, dashboards, and metric displays in Paseo conversations using pfr-form code fences.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"files": [
|
|
7
|
+
"README.md",
|
|
8
|
+
"LICENSE",
|
|
9
|
+
"paseo-plugin.json",
|
|
10
|
+
"index.client.tsx",
|
|
11
|
+
"index.server.ts",
|
|
12
|
+
"client/",
|
|
13
|
+
"server/",
|
|
14
|
+
"shared/",
|
|
15
|
+
"!**/*.test.ts"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"pack:offline": "node scripts/pack-offline.mjs",
|
|
21
|
+
"e2e": "npm --prefix e2e run e2e --"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@getpaseo/client": "^0.11.2",
|
|
25
|
+
"@getpaseo/plugin": "^0.11.2",
|
|
26
|
+
"@tanstack/react-query": "^5.90.11",
|
|
27
|
+
"@types/node": "^22.20.4",
|
|
28
|
+
"@types/react": "~19.2.0",
|
|
29
|
+
"react": "19.1.0",
|
|
30
|
+
"react-native": "0.81.5",
|
|
31
|
+
"typescript": "^5.9.3",
|
|
32
|
+
"vitest": "^4.1.11",
|
|
33
|
+
"zod": "^4.4.3"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"paseo",
|
|
37
|
+
"paseo-plugin",
|
|
38
|
+
"forms",
|
|
39
|
+
"dashboards",
|
|
40
|
+
"interactive-ui",
|
|
41
|
+
"form-render"
|
|
42
|
+
],
|
|
43
|
+
"author": "Weiqiang",
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/quickwind/paseo-form-render.git"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/quickwind/paseo-form-render#readme",
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/quickwind/paseo-form-render/issues"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import {
|
|
3
|
+
buildFixMessage,
|
|
4
|
+
collectTurnFormFailures,
|
|
5
|
+
countTrailingFixAttempts,
|
|
6
|
+
type FormFailure,
|
|
7
|
+
type TimelineEntry,
|
|
8
|
+
} from "../shared/form-fix";
|
|
9
|
+
import { readPfrSettings, type PfrSettingsHandle } from "./settings";
|
|
10
|
+
|
|
11
|
+
/** How many of the newest timeline entries to fetch when checking the conversation history. */
|
|
12
|
+
const HISTORY_LIMIT = 300;
|
|
13
|
+
|
|
14
|
+
export type AutoFixSkipReason =
|
|
15
|
+
| "not-completed"
|
|
16
|
+
| "no-failures"
|
|
17
|
+
| "disabled"
|
|
18
|
+
| "history-unavailable"
|
|
19
|
+
| "history-truncated"
|
|
20
|
+
| "superseded"
|
|
21
|
+
| "limit-reached"
|
|
22
|
+
| "busy"
|
|
23
|
+
| "status-unavailable";
|
|
24
|
+
|
|
25
|
+
export type AutoFixPlan = { ok: false; reason: AutoFixSkipReason } | { ok: true; failures: FormFailure[] };
|
|
26
|
+
|
|
27
|
+
export type AutoFixDecision =
|
|
28
|
+
| { send: false; reason: AutoFixSkipReason }
|
|
29
|
+
| { send: true; message: string; attempt: number };
|
|
30
|
+
|
|
31
|
+
export interface TurnInput {
|
|
32
|
+
/** The `timeline` of the `agent.turn_ended` event. It may cover only the finished turn. */
|
|
33
|
+
turnTimeline: readonly TimelineEntry[];
|
|
34
|
+
outcome: { readonly kind: string };
|
|
35
|
+
/** Maximum consecutive fix requests; 0 disables auto-fix. */
|
|
36
|
+
maxRetries: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AutoFixInput extends TurnInput {
|
|
40
|
+
/** The newest part of the conversation, fetched just before sending. Missing or null means it could not be read. */
|
|
41
|
+
latestTimeline?: readonly TimelineEntry[] | null;
|
|
42
|
+
/** True when older entries exist beyond `latestTimeline`. */
|
|
43
|
+
latestTruncated?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Cheap first gate that needs only the event: is there anything to fix, and is fixing on? */
|
|
47
|
+
export function planAutoFix({ turnTimeline, outcome, maxRetries }: TurnInput): AutoFixPlan {
|
|
48
|
+
if (outcome.kind !== "completed") return { ok: false, reason: "not-completed" };
|
|
49
|
+
const failures = collectTurnFormFailures(turnTimeline);
|
|
50
|
+
if (failures.length === 0) return { ok: false, reason: "no-failures" };
|
|
51
|
+
if (maxRetries <= 0) return { ok: false, reason: "disabled" };
|
|
52
|
+
return { ok: true, failures };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isRealUserMessage(entry: TimelineEntry): boolean {
|
|
56
|
+
return entry.type === "user_message" && countTrailingFixAttempts([entry]) === 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decides whether to ask the agent to repair its pfr-form blocks. Failures come from the finished
|
|
61
|
+
* turn; the retry count and "did anything newer happen" checks use the freshly fetched history,
|
|
62
|
+
* and the answer is never to send when that history is unavailable.
|
|
63
|
+
*/
|
|
64
|
+
export function decideAutoFix(input: AutoFixInput): AutoFixDecision {
|
|
65
|
+
const plan = planAutoFix(input);
|
|
66
|
+
if (!plan.ok) return { send: false, reason: plan.reason };
|
|
67
|
+
|
|
68
|
+
const latest = input.latestTimeline;
|
|
69
|
+
if (!latest) return { send: false, reason: "history-unavailable" };
|
|
70
|
+
|
|
71
|
+
// A user message after the last assistant output means the conversation moved on since this turn
|
|
72
|
+
// ended (the user typed something, or a fix request is already on its way).
|
|
73
|
+
const lastAssistant = lastIndexOf(latest, (e) => e.type === "assistant_message");
|
|
74
|
+
const lastUser = lastIndexOf(latest, (e) => e.type === "user_message");
|
|
75
|
+
if (lastAssistant < 0 || lastUser > lastAssistant) return { send: false, reason: "superseded" };
|
|
76
|
+
|
|
77
|
+
// If the finished turn shows the message that started it, the newest user message in the history
|
|
78
|
+
// must be that same message; anything else is a newer request that has already been answered.
|
|
79
|
+
// A fetched window with no user message at all holds only this turn's tail, so nothing newer
|
|
80
|
+
// arrived (a newer message would sit at the end of the window).
|
|
81
|
+
const turnLastUser = lastIndexOf(input.turnTimeline, (e) => e.type === "user_message");
|
|
82
|
+
if (lastUser >= 0 && turnLastUser >= 0 && latest[lastUser].text !== input.turnTimeline[turnLastUser].text) {
|
|
83
|
+
return { send: false, reason: "superseded" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Count from the fetched history; when a long turn pushed every real user message out of that
|
|
87
|
+
// window, fall back to the event timeline, which may hold the full history.
|
|
88
|
+
let countFrom: readonly TimelineEntry[] = latest;
|
|
89
|
+
if (input.latestTruncated && lastIndexOf(latest, isRealUserMessage) < 0) {
|
|
90
|
+
if (lastIndexOf(input.turnTimeline, isRealUserMessage) < 0) return { send: false, reason: "history-truncated" };
|
|
91
|
+
countFrom = input.turnTimeline;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const attempts = countTrailingFixAttempts(countFrom);
|
|
95
|
+
if (attempts >= input.maxRetries) return { send: false, reason: "limit-reached" };
|
|
96
|
+
|
|
97
|
+
return { send: true, message: buildFixMessage(plan.failures), attempt: attempts + 1 };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function lastIndexOf(entries: readonly TimelineEntry[], predicate: (entry: TimelineEntry) => boolean): number {
|
|
101
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
102
|
+
if (predicate(entries[i])) return i;
|
|
103
|
+
}
|
|
104
|
+
return -1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Reads timeline items out of a `timeline.refetch()` payload (`entries[].item`, `hasOlder`, `error`).
|
|
109
|
+
* Returns null when the payload reports an error or is not shaped as expected.
|
|
110
|
+
*/
|
|
111
|
+
export function timelineFromPayload(payload: unknown): { items: TimelineEntry[]; truncated: boolean } | null {
|
|
112
|
+
if (typeof payload !== "object" || payload === null) return null;
|
|
113
|
+
const { entries, error, hasOlder } = payload as { entries?: unknown; error?: unknown; hasOlder?: unknown };
|
|
114
|
+
if (error || !Array.isArray(entries)) return null;
|
|
115
|
+
|
|
116
|
+
const items: TimelineEntry[] = [];
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
const item = (entry as { item?: unknown } | null)?.item as { type?: unknown; text?: unknown } | undefined;
|
|
119
|
+
if (!item || typeof item.type !== "string") return null;
|
|
120
|
+
items.push({ type: item.type, text: typeof item.text === "string" ? item.text : undefined });
|
|
121
|
+
}
|
|
122
|
+
return { items, truncated: hasOlder === true };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Skip reason when the agent is already busy, or its status cannot be read; null when it is idle. */
|
|
126
|
+
export function idleGate(status: string | null | undefined): "busy" | "status-unavailable" | null {
|
|
127
|
+
if (!status) return "status-unavailable";
|
|
128
|
+
return status === "idle" ? null : "busy";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function checkAgentIdle(agent: {
|
|
132
|
+
refresh(): Promise<unknown>;
|
|
133
|
+
readonly status: string | null;
|
|
134
|
+
}): Promise<"busy" | "status-unavailable" | null> {
|
|
135
|
+
try {
|
|
136
|
+
await agent.refresh();
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.warn("[paseo-form-render] could not refresh agent status", error);
|
|
139
|
+
return "status-unavailable";
|
|
140
|
+
}
|
|
141
|
+
return idleGate(agent.status);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Registers the turn-end hook that sends automatic fix requests. */
|
|
145
|
+
export function registerAutoFix(server: PluginServerContext, settings: PfrSettingsHandle) {
|
|
146
|
+
async function readMaxRetries(): Promise<number> {
|
|
147
|
+
return (await readPfrSettings(settings)).autoFixMaxRetries;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return server.on("agent.turn_ended", async (event, { paseo, signal }) => {
|
|
151
|
+
try {
|
|
152
|
+
const turn: TurnInput = {
|
|
153
|
+
turnTimeline: event.timeline,
|
|
154
|
+
outcome: event.outcome,
|
|
155
|
+
maxRetries: await readMaxRetries(),
|
|
156
|
+
};
|
|
157
|
+
if (!planAutoFix(turn).ok) return;
|
|
158
|
+
|
|
159
|
+
// Yield first so the send never runs re-entrantly inside the turn-end dispatch. The hook stays
|
|
160
|
+
// pending until the send settles because its `paseo` handle may be scoped to this invocation.
|
|
161
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
162
|
+
if (signal.aborted) return;
|
|
163
|
+
|
|
164
|
+
const agent = paseo.agents.ref(event.agent.id);
|
|
165
|
+
let history: ReturnType<typeof timelineFromPayload> = null;
|
|
166
|
+
try {
|
|
167
|
+
history = timelineFromPayload(
|
|
168
|
+
await agent.timeline.refetch({ direction: "tail", limit: HISTORY_LIMIT, projection: "projected" }),
|
|
169
|
+
);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.warn("[paseo-form-render] could not fetch agent history, skipping auto-fix", error);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const decision = decideAutoFix({
|
|
176
|
+
...turn,
|
|
177
|
+
latestTimeline: history?.items ?? null,
|
|
178
|
+
latestTruncated: history?.truncated,
|
|
179
|
+
});
|
|
180
|
+
if (!decision.send) {
|
|
181
|
+
if (decision.reason === "history-unavailable" || decision.reason === "history-truncated") {
|
|
182
|
+
console.warn(`[paseo-form-render] skipping auto-fix: ${decision.reason}`);
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// `send` interrupts a running turn, so a message that arrived after the history fetch would be
|
|
187
|
+
// cancelled. Re-read the status right before sending and leave a running agent alone. A small
|
|
188
|
+
// window remains: Paseo has no "send only if idle" option (see README).
|
|
189
|
+
const gate = await checkAgentIdle(agent);
|
|
190
|
+
if (gate) {
|
|
191
|
+
if (gate === "status-unavailable") console.warn("[paseo-form-render] skipping auto-fix: status-unavailable");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
await agent.send(decision.message);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
console.warn("[paseo-form-render] auto-fix hook failed", error);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
package/server/demo.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RpcInput } from "@getpaseo/plugin";
|
|
2
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
3
|
+
import { DEMO_FORM_TEXT, TIMELINE_KIND, type showDemoForm } from "../shared/demo";
|
|
4
|
+
|
|
5
|
+
type Paseo = Parameters<Parameters<PluginServerContext["handle"]>[1]>[1]["paseo"];
|
|
6
|
+
|
|
7
|
+
/** Adds the demo form as a row the form renderer draws; the agent is not prompted. */
|
|
8
|
+
export async function appendDemoForm(
|
|
9
|
+
{ agentId }: RpcInput<typeof showDemoForm>,
|
|
10
|
+
{ paseo }: { paseo: Paseo },
|
|
11
|
+
) {
|
|
12
|
+
await paseo.agents.ref(agentId).timeline.append({
|
|
13
|
+
type: "plugin",
|
|
14
|
+
id: `pfr-form-demo-${Date.now()}`,
|
|
15
|
+
kind: TIMELINE_KIND,
|
|
16
|
+
version: 1,
|
|
17
|
+
data: { text: DEMO_FORM_TEXT },
|
|
18
|
+
});
|
|
19
|
+
return {};
|
|
20
|
+
}
|