@fml-inc/fml 0.8.10-dev.1 → 0.8.10
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/.claude-plugin/plugin.json +1 -1
- package/README.md +114 -3
- package/agent-assets/mini-swe-agent/FML_WORKFLOW.md +62 -0
- package/agent-assets/mini-swe-agent/fml_mini_agent.py +483 -0
- package/agent-assets/mini-swe-agent/fml_mini_bootstrap.py +141 -0
- package/bin/fml +18 -4
- package/dist/{chunk-2DGHAVVR.js → chunk-OUXFHL3I.js} +1 -1
- package/dist/{chunk-JPHN4VKM.js → chunk-PTDADW47.js} +197 -94
- package/dist/{chunk-5SI6S4HW.js → chunk-U4BM27VN.js} +1 -1
- package/dist/{chunk-5PEFBQNX.js → chunk-XB7EIDWY.js} +17 -4
- package/dist/cli.js +4600 -3882
- package/dist/hook-handler.js +39 -11
- package/dist/mcp/server.js +5 -5
- package/dist/observer-server.js +5 -5
- package/dist/pi/extension.js +17 -4
- package/dist/sync-cycle.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,6 +66,117 @@ plugin-namespaced skill.
|
|
|
66
66
|
For a coding agent operating in a sandbox, use the
|
|
67
67
|
[agent installation guide](./docs/agent_installation_guide.md).
|
|
68
68
|
|
|
69
|
+
### mini-swe-agent v2
|
|
70
|
+
|
|
71
|
+
FML supports native v2 `DefaultAgent`/`InteractiveAgent` with `LocalEnvironment`.
|
|
72
|
+
Install mini and its dependencies yourself (Python >=3.10, Pydantic >=2), activate
|
|
73
|
+
that environment, and retain your native model credentials:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
fml install
|
|
77
|
+
mini -t "Your task" -o ./mini-run.traj.json
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Successful Mini setup uses the shared installation summary; Mini-specific output
|
|
81
|
+
is reserved for warnings.
|
|
82
|
+
|
|
83
|
+
Native `mini-swe-agent` and `python -m minisweagent` entrypoints also work.
|
|
84
|
+
No wrapper, `PYTHONPATH` export, or adapter flag is required. Installation probes
|
|
85
|
+
package metadata without importing mini or loading its `.env`; activation needs
|
|
86
|
+
writable site-packages. An absent Mini installation or unrelated executable named
|
|
87
|
+
`mini` is optional, not a warning. An existing activation is retained when Mini
|
|
88
|
+
is unavailable. Recognized Mini prerequisite failures and activation conflicts
|
|
89
|
+
still warn without blocking general FML setup.
|
|
90
|
+
|
|
91
|
+
An ASCII-only owned `.pth` loads a guarded bootstrap bound to the selected Python
|
|
92
|
+
prefix, including paths with non-ASCII characters.
|
|
93
|
+
Hook delivery uses owned absolute Node and hook-handler paths recorded at
|
|
94
|
+
installation; it does not resolve `fml` or Node from the runtime PATH.
|
|
95
|
+
Automatic capture leaves ordinary Python commands and other environments native,
|
|
96
|
+
including environments inheriting that site-packages directory. Native YAML,
|
|
97
|
+
`.env`, model/mode/limit/output settings, and configuration edits remain
|
|
98
|
+
authoritative. `fml doctor` silently checks prerequisites, activation, and ownership
|
|
99
|
+
of the managed adapter and hook-command assets. It shows a Mini-specific warning
|
|
100
|
+
only when a recognized installation needs attention; missing or modified assets
|
|
101
|
+
warn without changing files. Installation does not guarantee capture for custom
|
|
102
|
+
configurations.
|
|
103
|
+
|
|
104
|
+
Explicit `-c`/`--config` (including short-option clusters such as `-yc`) and
|
|
105
|
+
`--agent-class` opt out unless they select an FML adapter. Supplying `-c` replaces
|
|
106
|
+
mini's default config list: include the native base first, such as
|
|
107
|
+
`-c mini.yaml -c local-overlay.yaml`. CLI class selection wins
|
|
108
|
+
over YAML. To capture an explicit configuration, select
|
|
109
|
+
`--agent-class fml_mini_agent.FmlInteractiveAgent` or
|
|
110
|
+
`--agent-class fml_mini_agent.FmlDefaultAgent`.
|
|
111
|
+
Native `-o` overrides YAML `agent.output_path`, including its default when omitted.
|
|
112
|
+
Custom agents, remote environments, mini v1, and batch/SWE-bench are unsupported.
|
|
113
|
+
|
|
114
|
+
The adapter preserves native confirmation, human/yolo modes, interruptions,
|
|
115
|
+
completion, and exceptions. FML requests run through bash using the CLI-only
|
|
116
|
+
workflow; there is no native `/fml`, `$fml`, MCP client, or `fml review` command.
|
|
117
|
+
The bundled workflow is model-visible without being printed as a user message in
|
|
118
|
+
the interactive terminal. Capture-only interruptions after execution preserve
|
|
119
|
+
the native result or pending exception, including during observation snapshots,
|
|
120
|
+
before interrupting further native work.
|
|
121
|
+
Capture interruptions before native execution discard the pending execution
|
|
122
|
+
record, so the final snapshot does not report a command that never ran.
|
|
123
|
+
|
|
124
|
+
Each `run()` receives a UUID; interactive continuation keeps it. Native output is
|
|
125
|
+
unchanged. Atomic snapshots retain the complete native trajectory plus FML
|
|
126
|
+
identity, actual execution, context, cwd, and message-observation metadata under
|
|
127
|
+
`<FML data directory>/mini-swe-agent/trajectories/<UUID>.traj.json`.
|
|
128
|
+
Managed snapshots omit native `info.config.model.model_kwargs` and
|
|
129
|
+
`info.config.environment.env`; native configuration and output retain both.
|
|
130
|
+
Messages and tool output remain raw, not generally credential-redacted.
|
|
131
|
+
Sync replaces the session projection as this full JSON snapshot changes, rather
|
|
132
|
+
than appending JSONL. Rejected proposals are not executed bash calls, and injected
|
|
133
|
+
context is not a human prompt. Usage views count reasoning/cache tokens once,
|
|
134
|
+
including native OpenRouter cache-write details; unknown usage remains unknown.
|
|
135
|
+
Native cumulative costs are scoped to each run.
|
|
136
|
+
|
|
137
|
+
Snapshots require `fml.human_message_indices`, recorded at native input boundaries
|
|
138
|
+
and tied to retained message objects. Blank interruption and mode-switch feedback
|
|
139
|
+
do not create human turns; genuine comments and human commands still do, even
|
|
140
|
+
when their text matches generated feedback. Projection does not infer provenance
|
|
141
|
+
from message text or accept snapshots missing this metadata.
|
|
142
|
+
Provenance is recorded before interruptible hook delivery, so accepted native
|
|
143
|
+
input keeps its human role if capture is interrupted.
|
|
144
|
+
Human-mode shell commands retain their raw whitespace, including mode-like text
|
|
145
|
+
that native Mini treats as a command. Confirmation and interruption responses
|
|
146
|
+
follow their native caller's input decision instead of a global trim heuristic.
|
|
147
|
+
|
|
148
|
+
Capture uses the bounded, fail-open hook inbox and authenticated destination
|
|
149
|
+
binding. Dynamic server context is supported, but disabled for mini by default.
|
|
150
|
+
Admins can opt an organization in through the existing dynamic-context policy
|
|
151
|
+
with the Convex-safe override `{"harnesses":{"miniSweAgent":true}}`; organization
|
|
152
|
+
override lookup failures leave mini disabled. Other harness defaults are unchanged,
|
|
153
|
+
and the public target/source remains `mini-swe-agent`.
|
|
154
|
+
|
|
155
|
+
The hook's JSON transport uses explicit UTF-8 encoding, independent of Python's
|
|
156
|
+
locale, so Unicode context is preserved at the native model boundary.
|
|
157
|
+
|
|
158
|
+
The initial native task and actual interactive human prompts arm a `BeforeModel`
|
|
159
|
+
request immediately before the next provider call. Native slash/mode controls,
|
|
160
|
+
generated feedback, and tool continuations do not rearm it. Returned context is
|
|
161
|
+
inserted quietly into native messages without printing a user message; snapshots
|
|
162
|
+
record its position in `fml.context_message_indices`, so it is not classified as
|
|
163
|
+
human input. Both requests and insertion honor pause state, including a pause
|
|
164
|
+
while a request is in flight. Deferred capture interruptions are delivered before
|
|
165
|
+
the next provider call or tool execution, without replacing completed native
|
|
166
|
+
results or exceptions.
|
|
167
|
+
|
|
168
|
+
`fml stop` pauses hooks, context, and snapshots, not native history; resuming
|
|
169
|
+
publishes the complete cumulative run, including its paused interval.
|
|
170
|
+
Reinstall repairs a missing owned `.pth` after environment revalidation.
|
|
171
|
+
When Mini resolves to a different interpreter, reinstall moves the unchanged
|
|
172
|
+
owned activation to that environment; no prior uninstall is required.
|
|
173
|
+
Modified hooks and destination conflicts are preserved with a warning.
|
|
174
|
+
Updates retain activation if mini is unavailable. Uninstall removes unchanged
|
|
175
|
+
owned files without editing native YAML or `.env`; modified/conflicting files
|
|
176
|
+
retain their dependencies with a warning. Trajectories remain unless `--purge`
|
|
177
|
+
is explicit, and unrelated or modified files are preserved even during purge.
|
|
178
|
+
Resolve reported ownership conflicts manually.
|
|
179
|
+
|
|
69
180
|
## Upgrading from standalone Panopticon
|
|
70
181
|
|
|
71
182
|
Earlier releases shipped as a separate `@fml-inc/panopticon` package that
|
|
@@ -98,9 +209,9 @@ them into local session, message, tool-call, or scanner tables. Server-side
|
|
|
98
209
|
scanners own parsing and projections.
|
|
99
210
|
|
|
100
211
|
Discovery covers supported local stores across Claude Code, Codex, Gemini,
|
|
101
|
-
Grok, Cursor, Pi, Copilot CLI, and Factory Droid, including
|
|
102
|
-
|
|
103
|
-
the machine.
|
|
212
|
+
Grok, Cursor, Pi, mini-swe-agent, Copilot CLI, and Factory Droid, including
|
|
213
|
+
archived Codex sessions and managed mini full-trajectory snapshots.
|
|
214
|
+
Availability depends on which harnesses have written artifacts on the machine.
|
|
104
215
|
|
|
105
216
|
Sync has one persistent setting: enabled or disabled. It defaults to enabled.
|
|
106
217
|
The backend environment, login, and selected organization determine the upload
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<!-- fml-managed-agent-surface:v1 -->
|
|
2
|
+
|
|
3
|
+
# FML workflow for mini-swe-agent
|
|
4
|
+
|
|
5
|
+
Use FML only when explicitly requested. Invoke its real CLI through bash;
|
|
6
|
+
mini has no native FML skill, slash command, or MCP client. With no requested
|
|
7
|
+
command, give concise help rather than performing an operation.
|
|
8
|
+
|
|
9
|
+
## CLI routing and safety
|
|
10
|
+
|
|
11
|
+
- Select `review` before CLI discovery: it is the agent workflow below, **not**
|
|
12
|
+
a CLI command. Never execute `fml review` or delegate the review to the CLI.
|
|
13
|
+
- Discover commands with `fml commands` and `fml <command> --help`. Discover
|
|
14
|
+
backend tools with `fml tools list --json` and `fml tools describe <name> --json`;
|
|
15
|
+
never guess tool names or schemas.
|
|
16
|
+
- Call described tools with `fml tools call <name> --args '<json>'` or `--file <path>`
|
|
17
|
+
for nested/multiline JSON. Use the CLI for install, authentication, environments,
|
|
18
|
+
observer, sync, and read-only structured data.
|
|
19
|
+
- Activity/history reads synced data in the selected organization, not a local
|
|
20
|
+
analytics database. Summarize large results unless raw output was requested.
|
|
21
|
+
- Apply the user's approval rules to mutations. Explain destructive operations,
|
|
22
|
+
including `sync reset` and `uninstall --purge`, before executing them. Native
|
|
23
|
+
mini confirmation is not a substitute for approval. Do not silently mutate
|
|
24
|
+
integrations, automations, memory, or analyses.
|
|
25
|
+
- Diagnose with `fml doctor`; use `fml login --device` when browser callbacks are
|
|
26
|
+
unavailable. Never print credentials or tokens. Report unavailable commands
|
|
27
|
+
and failures without inventing results or retrying indefinitely.
|
|
28
|
+
|
|
29
|
+
## FML review
|
|
30
|
+
|
|
31
|
+
1. Read repository instructions. Establish the branch and explicit/PR base;
|
|
32
|
+
otherwise use `refs/remotes/origin/HEAD`, then local `main` or `master`.
|
|
33
|
+
Do not fetch or mutate branches solely for review. Ask if no reliable base exists.
|
|
34
|
+
2. Inspect committed and working-tree changes separately:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
git status --short
|
|
38
|
+
git diff --stat <base>...HEAD
|
|
39
|
+
git diff --find-renames <base>...HEAD
|
|
40
|
+
git log --oneline <base>..HEAD
|
|
41
|
+
git diff
|
|
42
|
+
git diff --cached
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
3. Read complete changed code and relevant callers, tests, schemas, and config.
|
|
46
|
+
State the base and whether uncommitted changes are included.
|
|
47
|
+
4. Prioritize correctness, security/auth, secrets/privacy, data loss, migrations,
|
|
48
|
+
concurrency, partial failure, idempotency, public/platform contracts, packaging,
|
|
49
|
+
install/removal symmetry, and meaningful regression coverage. Trace actual
|
|
50
|
+
execution paths; report concrete triggers, impact, repair, and file/line evidence.
|
|
51
|
+
Do not report formatting preferences or speculative defects.
|
|
52
|
+
5. Finish code review before consulting FML for uncertain intent. Discover and
|
|
53
|
+
describe read-only tools before use: `fml_commit_context`, `fml_path_history`,
|
|
54
|
+
`fml_pr_context`, or `search_engineering_sessions` then `get_session_timeline`.
|
|
55
|
+
CLI routes include `fml search`, `fml sessions`, and `fml timeline`; inspect help.
|
|
56
|
+
If unavailable or irrelevant, state the limit and finish from code evidence.
|
|
57
|
+
Historical intent never excuses a concrete defect.
|
|
58
|
+
6. Report severity-ordered **Findings**, material **Questions / assumptions**, and
|
|
59
|
+
**Verdict**: Request changes, Approve with comments, or Approve. Use P0 for
|
|
60
|
+
catastrophic/release-blocking defects, P1 serious, P2 normal correctness, P3 minor.
|
|
61
|
+
Name meaningful evidence/test gaps even with no findings. Put FML-resolved intent
|
|
62
|
+
after the verdict in **Follow-up: FML-sourced intent** only when it was used.
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
# <!-- fml-managed-agent-surface:v1 -->
|
|
2
|
+
"""FML adapters for mini-swe-agent v2's native local agents.
|
|
3
|
+
|
|
4
|
+
fml install activates these with an interpreter-local, native-CLI bootstrap.
|
|
5
|
+
Native configuration and explicit CLI overrides remain authoritative. This
|
|
6
|
+
module never launches mini; native serialization and output remain native.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from functools import wraps
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from minisweagent import __version__
|
|
21
|
+
if __version__.split(".")[0] != "2":
|
|
22
|
+
raise RuntimeError("FML supports mini-swe-agent v2 only; use your existing v2 installation.")
|
|
23
|
+
from minisweagent.agents.default import DefaultAgent
|
|
24
|
+
from minisweagent.agents.interactive import InteractiveAgent
|
|
25
|
+
from minisweagent.environments.local import LocalEnvironment
|
|
26
|
+
from minisweagent.exceptions import Submitted
|
|
27
|
+
from minisweagent.models.utils.content_string import get_content_string
|
|
28
|
+
except ImportError as error:
|
|
29
|
+
raise RuntimeError(
|
|
30
|
+
"FML requires an existing mini-swe-agent v2 installation and its dependencies in this Python environment."
|
|
31
|
+
) from error
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
HOOK_TIMEOUT_SECONDS = 3.5
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _now():
|
|
38
|
+
return datetime.now(timezone.utc).isoformat()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _override(name):
|
|
42
|
+
value = os.environ.get(name, "")
|
|
43
|
+
if not value.strip():
|
|
44
|
+
return None
|
|
45
|
+
path = Path(value)
|
|
46
|
+
if not path.is_absolute():
|
|
47
|
+
raise ValueError(f"{name} must be an absolute path: {value}")
|
|
48
|
+
return path
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _platform_base(name, fallback):
|
|
52
|
+
value = os.environ.get(name, "")
|
|
53
|
+
return Path(value) if value.strip() and Path(value).is_absolute() else fallback
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _data_dir():
|
|
57
|
+
override = _override("FML_DATA_DIR")
|
|
58
|
+
if override is not None:
|
|
59
|
+
return override
|
|
60
|
+
home = Path.home()
|
|
61
|
+
if sys.platform == "darwin":
|
|
62
|
+
return home / "Library" / "Application Support" / "fml"
|
|
63
|
+
if sys.platform == "win32":
|
|
64
|
+
return _platform_base("APPDATA", home / "AppData" / "Roaming") / "fml"
|
|
65
|
+
return _platform_base("XDG_DATA_HOME", home / ".local" / "share") / "fml"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _observer_dir():
|
|
69
|
+
for name in ("FML_OBSERVER_DIR", "PANOPTICON_HOME"):
|
|
70
|
+
override = _override(name)
|
|
71
|
+
if override is not None:
|
|
72
|
+
return override
|
|
73
|
+
home = _override("FML_HOME")
|
|
74
|
+
return home / "panopticon" if home is not None else Path.home() / ".fml" / "panopticon"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _capture_enabled():
|
|
78
|
+
# An imported, subsequently uninstalled adapter must not resume capture.
|
|
79
|
+
if not Path(__file__).is_file():
|
|
80
|
+
return False
|
|
81
|
+
try:
|
|
82
|
+
(_observer_dir() / "hook-capture.disabled").lstat()
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
return True
|
|
85
|
+
except (OSError, ValueError):
|
|
86
|
+
return False
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _hook_command():
|
|
91
|
+
try:
|
|
92
|
+
command = json.loads(Path(__file__).with_name("fml_hook_command.json").read_text(encoding="utf-8"))
|
|
93
|
+
if isinstance(command, list) and len(command) == 2 and all(
|
|
94
|
+
isinstance(part, str) and Path(part).is_absolute() for part in command
|
|
95
|
+
):
|
|
96
|
+
return command
|
|
97
|
+
except (OSError, ValueError, TypeError):
|
|
98
|
+
pass
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _capture_interrupts(method):
|
|
103
|
+
"""Route capture-only Ctrl-C without replacing pending native exceptions."""
|
|
104
|
+
@wraps(method)
|
|
105
|
+
def capture(self, *args, **kwargs):
|
|
106
|
+
pending_exception = sys.exc_info()[0] is not None
|
|
107
|
+
try:
|
|
108
|
+
return method(self, *args, **kwargs)
|
|
109
|
+
except KeyboardInterrupt:
|
|
110
|
+
ended = self.messages and self.messages[-1].get("role") == "exit"
|
|
111
|
+
if not (self._fml_ending or ended or pending_exception) and (
|
|
112
|
+
self._fml_in_step or not isinstance(self, InteractiveAgent)
|
|
113
|
+
):
|
|
114
|
+
raise
|
|
115
|
+
self._fml_interrupted = True
|
|
116
|
+
return {}
|
|
117
|
+
return capture
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class _FmlMixin:
|
|
122
|
+
def __init__(self, model, env, **kwargs):
|
|
123
|
+
if type(env) is not LocalEnvironment:
|
|
124
|
+
raise ValueError("FML mini-swe-agent supports the native v2 LocalEnvironment only (no remote/batch environments).")
|
|
125
|
+
super().__init__(model, env, **kwargs)
|
|
126
|
+
self._fml = None
|
|
127
|
+
self._fml_hook_command = None
|
|
128
|
+
self._fml_message_observations = {}
|
|
129
|
+
self._fml_event_ordinal = 0
|
|
130
|
+
self._fml_action_message_index = None
|
|
131
|
+
self._fml_inserting_context = False
|
|
132
|
+
self._fml_quiet_context = False
|
|
133
|
+
self._fml_pending_prompt = None
|
|
134
|
+
self._fml_pending_response = None
|
|
135
|
+
self._fml_initial_task_pending = False
|
|
136
|
+
self._fml_prompt_depth = 0
|
|
137
|
+
self._fml_workflow_inserted = False
|
|
138
|
+
self._fml_in_step = False
|
|
139
|
+
self._fml_interrupted = False
|
|
140
|
+
self._fml_ending = False
|
|
141
|
+
|
|
142
|
+
def _fml_cwd(self, cwd=""):
|
|
143
|
+
return str(Path(cwd or self.env.config.cwd or os.getcwd()).resolve())
|
|
144
|
+
|
|
145
|
+
@_capture_interrupts
|
|
146
|
+
def _fml_hook(self, event_name, **fields):
|
|
147
|
+
if self._fml is None or not _capture_enabled():
|
|
148
|
+
return {}
|
|
149
|
+
ordinal = self._fml_event_ordinal
|
|
150
|
+
self._fml_event_ordinal += 1
|
|
151
|
+
payload = {
|
|
152
|
+
"event_name": event_name,
|
|
153
|
+
"session_id": self._fml["session_id"],
|
|
154
|
+
"event_id": f'{self._fml["session_id"]}:{ordinal}',
|
|
155
|
+
"timestamp": _now(),
|
|
156
|
+
"cwd": self._fml["cwd"],
|
|
157
|
+
**fields,
|
|
158
|
+
}
|
|
159
|
+
try:
|
|
160
|
+
if self._fml_hook_command is None:
|
|
161
|
+
self._fml_hook_command = _hook_command()
|
|
162
|
+
if not self._fml_hook_command:
|
|
163
|
+
return {}
|
|
164
|
+
result = subprocess.run(
|
|
165
|
+
[*self._fml_hook_command, "mini-swe-agent"],
|
|
166
|
+
input=json.dumps(payload), text=True, encoding="utf-8", stdout=subprocess.PIPE,
|
|
167
|
+
stderr=subprocess.DEVNULL, timeout=HOOK_TIMEOUT_SECONDS, check=False,
|
|
168
|
+
)
|
|
169
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
170
|
+
response = json.loads(result.stdout)
|
|
171
|
+
return response if isinstance(response, dict) else {}
|
|
172
|
+
except (OSError, subprocess.SubprocessError, ValueError, TypeError):
|
|
173
|
+
pass
|
|
174
|
+
return {}
|
|
175
|
+
|
|
176
|
+
@_capture_interrupts
|
|
177
|
+
def _fml_snapshot(self):
|
|
178
|
+
if self._fml is None or not _capture_enabled():
|
|
179
|
+
return
|
|
180
|
+
temporary = None
|
|
181
|
+
try:
|
|
182
|
+
self._fml["updated_at"] = _now()
|
|
183
|
+
# Snapshot-only copies: native serialization/configuration still
|
|
184
|
+
# owns the opaque provider kwargs and environment overrides.
|
|
185
|
+
data = dict(self.serialize())
|
|
186
|
+
info = dict(data["info"])
|
|
187
|
+
config = dict(info["config"])
|
|
188
|
+
for section, field in (("model", "model_kwargs"), ("environment", "env")):
|
|
189
|
+
config[section] = {
|
|
190
|
+
key: value for key, value in config[section].items() if key != field
|
|
191
|
+
}
|
|
192
|
+
info["config"] = config
|
|
193
|
+
data["info"] = info
|
|
194
|
+
observed = [
|
|
195
|
+
self._fml_message_observations.get(id(message))
|
|
196
|
+
for message in self.messages
|
|
197
|
+
]
|
|
198
|
+
# Match the actual objects, not a historical ordinal: native runs
|
|
199
|
+
# reset the list, and restored messages may replace earlier rows.
|
|
200
|
+
observed_at = [
|
|
201
|
+
entry[1] if entry is not None and entry[0] is message else None
|
|
202
|
+
for message, entry in zip(self.messages, observed)
|
|
203
|
+
]
|
|
204
|
+
self._fml["message_observed_at_ms"] = observed_at
|
|
205
|
+
self._fml["human_message_indices"] = [
|
|
206
|
+
index for index, (message, entry) in enumerate(zip(self.messages, observed))
|
|
207
|
+
if entry is not None and entry[0] is message and entry[2]
|
|
208
|
+
]
|
|
209
|
+
self._fml["context_message_indices"] = [
|
|
210
|
+
index for index, (message, entry) in enumerate(zip(self.messages, observed))
|
|
211
|
+
if entry is not None and entry[0] is message and entry[3]
|
|
212
|
+
]
|
|
213
|
+
data["fml"] = self._fml
|
|
214
|
+
directory = _data_dir() / "mini-swe-agent" / "trajectories"
|
|
215
|
+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
216
|
+
destination = directory / f'{self._fml["session_id"]}.traj.json'
|
|
217
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=directory, prefix=".snapshot-", delete=False) as stream:
|
|
218
|
+
temporary = Path(stream.name)
|
|
219
|
+
json.dump(data, stream, ensure_ascii=False)
|
|
220
|
+
stream.flush()
|
|
221
|
+
os.fsync(stream.fileno())
|
|
222
|
+
# A pause arriving during serialization must not publish new bytes.
|
|
223
|
+
if not _capture_enabled():
|
|
224
|
+
return
|
|
225
|
+
os.replace(temporary, destination)
|
|
226
|
+
temporary = None
|
|
227
|
+
if os.name == "posix":
|
|
228
|
+
descriptor = os.open(directory, os.O_RDONLY)
|
|
229
|
+
try:
|
|
230
|
+
os.fsync(descriptor)
|
|
231
|
+
finally:
|
|
232
|
+
os.close(descriptor)
|
|
233
|
+
except Exception as error:
|
|
234
|
+
self.logger.debug("FML snapshot unavailable: %s", error)
|
|
235
|
+
finally:
|
|
236
|
+
if temporary is not None:
|
|
237
|
+
try:
|
|
238
|
+
temporary.unlink(missing_ok=True)
|
|
239
|
+
except OSError:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
def add_messages(self, *messages):
|
|
243
|
+
first_index = len(self.messages)
|
|
244
|
+
# Context bypasses InteractiveAgent's user-message printer, not native formatting.
|
|
245
|
+
result = DefaultAgent.add_messages(self, *messages) if self._fml_quiet_context else super().add_messages(*messages)
|
|
246
|
+
if self._fml is not None:
|
|
247
|
+
observed_at_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
|
|
248
|
+
for index, message in enumerate(self.messages[first_index:], first_index):
|
|
249
|
+
context = self._fml_inserting_context
|
|
250
|
+
initial_task = self._fml_initial_task_pending and index == 1 and message.get("role") == "user"
|
|
251
|
+
extra = message.get("extra", {})
|
|
252
|
+
# Carry raw input to the native message boundary. Observations
|
|
253
|
+
# may precede that message; each caller interprets modes itself.
|
|
254
|
+
response = self._fml_pending_response
|
|
255
|
+
interrupt_type = extra.get("interrupt_type")
|
|
256
|
+
human = not context and (initial_task or (
|
|
257
|
+
response is not None and message.get("role") == "user" and (
|
|
258
|
+
extra.get("actions")
|
|
259
|
+
or (interrupt_type in {"UserInterruption", "UserNewTask"}
|
|
260
|
+
and response.strip() not in self._MODE_COMMANDS_MAPPING)
|
|
261
|
+
or (interrupt_type == "UserRejection" and response.strip() != "/u")
|
|
262
|
+
)
|
|
263
|
+
))
|
|
264
|
+
self._fml_message_observations[id(message)] = (message, observed_at_ms, bool(human), context)
|
|
265
|
+
if human:
|
|
266
|
+
if initial_task:
|
|
267
|
+
self._fml_initial_task_pending = False
|
|
268
|
+
else:
|
|
269
|
+
self._fml_pending_response = None
|
|
270
|
+
self._fml_submit_prompt(response)
|
|
271
|
+
if initial_task and not context:
|
|
272
|
+
self._fml_submit_prompt(get_content_string(message))
|
|
273
|
+
self._fml_snapshot()
|
|
274
|
+
return result
|
|
275
|
+
|
|
276
|
+
def _fml_submit_prompt(self, prompt):
|
|
277
|
+
if self._fml is not None and isinstance(prompt, str) and prompt.strip():
|
|
278
|
+
self._fml_pending_prompt = prompt
|
|
279
|
+
self._fml_hook("UserPromptSubmit", prompt=prompt)
|
|
280
|
+
|
|
281
|
+
def _fml_add_context(self, content, *, quiet=False):
|
|
282
|
+
if not content or not isinstance(content, str):
|
|
283
|
+
return
|
|
284
|
+
self._fml_inserting_context = True
|
|
285
|
+
self._fml_quiet_context = quiet
|
|
286
|
+
try:
|
|
287
|
+
self.add_messages(self.model.format_message(role="user", content=content))
|
|
288
|
+
finally:
|
|
289
|
+
self._fml_inserting_context = False
|
|
290
|
+
self._fml_quiet_context = False
|
|
291
|
+
|
|
292
|
+
@_capture_interrupts
|
|
293
|
+
def _fml_before_model(self):
|
|
294
|
+
if not _capture_enabled():
|
|
295
|
+
return
|
|
296
|
+
if not self._fml_workflow_inserted:
|
|
297
|
+
try:
|
|
298
|
+
workflow = Path(__file__).with_name("FML_WORKFLOW.md").read_text(encoding="utf-8")
|
|
299
|
+
self._fml_add_context(workflow, quiet=True)
|
|
300
|
+
self._fml_workflow_inserted = True
|
|
301
|
+
except Exception as error:
|
|
302
|
+
self.logger.debug("FML workflow unavailable: %s", error)
|
|
303
|
+
if self._fml_pending_prompt is not None:
|
|
304
|
+
prompt = self._fml_pending_prompt
|
|
305
|
+
self._fml_pending_prompt = None
|
|
306
|
+
try:
|
|
307
|
+
response = self._fml_hook("BeforeModel", prompt=prompt)
|
|
308
|
+
if _capture_enabled():
|
|
309
|
+
self._fml_add_context(response.get("context"), quiet=True)
|
|
310
|
+
except Exception as error:
|
|
311
|
+
self.logger.debug("FML context unavailable: %s", error)
|
|
312
|
+
|
|
313
|
+
def step(self):
|
|
314
|
+
self._fml_in_step = True
|
|
315
|
+
try:
|
|
316
|
+
return super().step()
|
|
317
|
+
finally:
|
|
318
|
+
self._fml_in_step = False
|
|
319
|
+
|
|
320
|
+
def query(self):
|
|
321
|
+
if self._fml_interrupted:
|
|
322
|
+
self._fml_interrupted = False
|
|
323
|
+
raise KeyboardInterrupt
|
|
324
|
+
return super().query()
|
|
325
|
+
|
|
326
|
+
def execute_actions(self, message):
|
|
327
|
+
previous = self._fml_action_message_index
|
|
328
|
+
self._fml_action_message_index = next(
|
|
329
|
+
(index for index in range(len(self.messages) - 1, -1, -1) if self.messages[index] is message), None
|
|
330
|
+
)
|
|
331
|
+
try:
|
|
332
|
+
return super().execute_actions(message)
|
|
333
|
+
finally:
|
|
334
|
+
self._fml_action_message_index = previous
|
|
335
|
+
|
|
336
|
+
def save(self, path, *extra_dicts):
|
|
337
|
+
try:
|
|
338
|
+
return super().save(path, *extra_dicts)
|
|
339
|
+
finally:
|
|
340
|
+
ending = self._fml_ending
|
|
341
|
+
self._fml_ending = ending or sys.exc_info()[0] is not None
|
|
342
|
+
try:
|
|
343
|
+
self._fml_snapshot()
|
|
344
|
+
self._fml_hook("Stop")
|
|
345
|
+
finally:
|
|
346
|
+
self._fml_ending = ending
|
|
347
|
+
|
|
348
|
+
def run(self, task="", **kwargs):
|
|
349
|
+
if self._fml is not None:
|
|
350
|
+
raise RuntimeError("An FML mini agent cannot run concurrently on the same instance.")
|
|
351
|
+
started = _now()
|
|
352
|
+
self._fml = {
|
|
353
|
+
"version": 1, "session_id": str(uuid4()), "started_at": started,
|
|
354
|
+
"updated_at": started, "cwd": self._fml_cwd(),
|
|
355
|
+
"executions": [], "context_message_indices": [], "human_message_indices": [],
|
|
356
|
+
"model_stats_baseline": {"instance_cost": self.cost, "api_calls": self.n_calls},
|
|
357
|
+
}
|
|
358
|
+
self._fml_event_ordinal = 0
|
|
359
|
+
self._fml_hook_command = None
|
|
360
|
+
self._fml_message_observations = {}
|
|
361
|
+
self._fml_pending_prompt = None
|
|
362
|
+
self._fml_pending_response = None
|
|
363
|
+
self._fml_initial_task_pending = True
|
|
364
|
+
self._fml_prompt_depth = 0
|
|
365
|
+
self._fml_workflow_inserted = False
|
|
366
|
+
self._fml_interrupted = False
|
|
367
|
+
self._fml_ending = False
|
|
368
|
+
original_query = self.model.query
|
|
369
|
+
original_execute = self.env.execute
|
|
370
|
+
# Preserve instance attributes exactly, not a lingering bound-method shim.
|
|
371
|
+
query_attribute = self.model.__dict__.get("query")
|
|
372
|
+
execute_attribute = self.env.__dict__.get("execute")
|
|
373
|
+
had_query = "query" in self.model.__dict__
|
|
374
|
+
had_execute = "execute" in self.env.__dict__
|
|
375
|
+
|
|
376
|
+
def query(*args, **query_kwargs):
|
|
377
|
+
self._fml_before_model()
|
|
378
|
+
if self._fml_interrupted:
|
|
379
|
+
self._fml_interrupted = False
|
|
380
|
+
raise KeyboardInterrupt
|
|
381
|
+
return original_query(*args, **query_kwargs)
|
|
382
|
+
|
|
383
|
+
def execute(action, *args, **execute_kwargs):
|
|
384
|
+
if self._fml_interrupted:
|
|
385
|
+
self._fml_interrupted = False
|
|
386
|
+
raise KeyboardInterrupt
|
|
387
|
+
cwd = execute_kwargs.get("cwd", args[0] if args else "")
|
|
388
|
+
record = {
|
|
389
|
+
"id": f'execution-{len(self._fml["executions"])}',
|
|
390
|
+
"message_index": self._fml_action_message_index, "action": action,
|
|
391
|
+
"cwd": self._fml_cwd(cwd), "started_at": _now(),
|
|
392
|
+
}
|
|
393
|
+
self._fml["executions"].append(record)
|
|
394
|
+
try:
|
|
395
|
+
self._fml_snapshot()
|
|
396
|
+
except BaseException:
|
|
397
|
+
# No native execution started. Remove this exact pending row,
|
|
398
|
+
# not another execution, and preserve the original interruption.
|
|
399
|
+
executions = self._fml["executions"]
|
|
400
|
+
for index, pending in enumerate(executions):
|
|
401
|
+
if pending is record:
|
|
402
|
+
executions.pop(index)
|
|
403
|
+
break
|
|
404
|
+
raise
|
|
405
|
+
try:
|
|
406
|
+
output = original_execute(action, *args, **execute_kwargs)
|
|
407
|
+
record["output"] = output
|
|
408
|
+
return output
|
|
409
|
+
except BaseException as error:
|
|
410
|
+
record["exception"] = {"type": type(error).__name__, "message": str(error)}
|
|
411
|
+
if hasattr(error, "messages"):
|
|
412
|
+
record["exception"]["messages"] = error.messages
|
|
413
|
+
if isinstance(error, Submitted):
|
|
414
|
+
record["submitted"] = True
|
|
415
|
+
raise
|
|
416
|
+
finally:
|
|
417
|
+
record["completed_at"] = _now()
|
|
418
|
+
# Native execution has finished. Capture must not replace its
|
|
419
|
+
# return or pending exception; honor Ctrl-C before further work.
|
|
420
|
+
ending = self._fml_ending
|
|
421
|
+
self._fml_ending = True
|
|
422
|
+
try:
|
|
423
|
+
self._fml_snapshot()
|
|
424
|
+
self._fml_hook(
|
|
425
|
+
"PostToolUse", tool_name="bash", tool_input=action,
|
|
426
|
+
tool_result=record.get("output", {
|
|
427
|
+
"exception": record.get("exception"), "submitted": record.get("submitted", False),
|
|
428
|
+
}),
|
|
429
|
+
)
|
|
430
|
+
finally:
|
|
431
|
+
self._fml_ending = ending
|
|
432
|
+
|
|
433
|
+
self.model.query = query
|
|
434
|
+
self.env.execute = execute
|
|
435
|
+
try:
|
|
436
|
+
self._fml_hook("SessionStart")
|
|
437
|
+
return super().run(task, **kwargs)
|
|
438
|
+
finally:
|
|
439
|
+
# No next step exists: final capture must not replace native results
|
|
440
|
+
# or exceptions, including a native KeyboardInterrupt.
|
|
441
|
+
self._fml_ending = True
|
|
442
|
+
try:
|
|
443
|
+
self._fml_snapshot()
|
|
444
|
+
self._fml_hook("SessionEnd")
|
|
445
|
+
finally:
|
|
446
|
+
if had_query:
|
|
447
|
+
self.model.query = query_attribute
|
|
448
|
+
else:
|
|
449
|
+
del self.model.query
|
|
450
|
+
if had_execute:
|
|
451
|
+
self.env.execute = execute_attribute
|
|
452
|
+
else:
|
|
453
|
+
del self.env.execute
|
|
454
|
+
self._fml = None
|
|
455
|
+
self._fml_message_observations = {}
|
|
456
|
+
self._fml_pending_prompt = None
|
|
457
|
+
self._fml_pending_response = None
|
|
458
|
+
self._fml_initial_task_pending = False
|
|
459
|
+
self._fml_prompt_depth = 0
|
|
460
|
+
self._fml_interrupted = False
|
|
461
|
+
self._fml_ending = False
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
class FmlDefaultAgent(_FmlMixin, DefaultAgent):
|
|
465
|
+
"""DefaultAgent with bounded FML capture/context at native boundaries."""
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class FmlInteractiveAgent(_FmlMixin, InteractiveAgent):
|
|
469
|
+
"""InteractiveAgent retaining native confirmation, human and interrupt flow."""
|
|
470
|
+
|
|
471
|
+
def _prompt_and_handle_slash_commands(self, prompt, *, _multiline=False):
|
|
472
|
+
# Delegate all prompting/mode/confirmation behavior to native Mini. Its
|
|
473
|
+
# recursive help/multiline prompts produce one final human response.
|
|
474
|
+
if self._fml_prompt_depth == 0:
|
|
475
|
+
self._fml_pending_response = None
|
|
476
|
+
self._fml_prompt_depth += 1
|
|
477
|
+
try:
|
|
478
|
+
response = super()._prompt_and_handle_slash_commands(prompt, _multiline=_multiline)
|
|
479
|
+
finally:
|
|
480
|
+
self._fml_prompt_depth -= 1
|
|
481
|
+
if self._fml_prompt_depth == 0 and self._fml is not None and response.strip():
|
|
482
|
+
self._fml_pending_response = response
|
|
483
|
+
return response
|