@fml-inc/fml 0.8.10-dev.1 → 0.8.11-rc.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fml",
3
- "version": "0.8.10-dev.1",
3
+ "version": "0.8.11-rc.1",
4
4
  "description": "FML agent tools for Claude Code",
5
5
  "mcpServers": {
6
6
  "fml": {
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 archived Codex
102
- sessions. Availability depends on which harnesses have written artifacts on
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