-
Notifications
You must be signed in to change notification settings - Fork 21
feat: integrate LogAnalysisBot for enhanced failure analysis in auto_memory #175
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,104 +1,209 @@ | ||
| """Failure analysis utilities that convert callback results into structured feedback.""" | ||
| """Failure analysis that uses LogAnalysisBot to turn callback failures into feedback.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import tempfile | ||
| from logging import getLogger | ||
| from pathlib import Path | ||
|
|
||
| from microbots.auto_memory.callbacks import CallbackResult | ||
| from microbots.auto_memory.data_models import Feedback | ||
| from microbots.bot.LogAnalysisBot import LogAnalysisBot | ||
|
|
||
| # Maximum bytes read from a log tail when building root-cause snippets. | ||
| _MAX_LOG_BYTES = 4096 | ||
| logger = getLogger(__name__) | ||
|
|
||
| # Per-stream tail cap when assembling the combined log fed to the LLM | ||
| # analyzer. Prevents pathological callback outputs (e.g. verbose pytest | ||
| # tracebacks or dumped fixtures) from blowing up model context / cost. | ||
| _LOG_TAIL_BYTES = 16 * 1024 | ||
|
|
||
|
|
||
| def analyze_failure( | ||
| callback_results: list[CallbackResult], | ||
| candidate_path: Path, | ||
| iteration_idx: int, | ||
| *, | ||
| analyzer_model: str, | ||
| analyzer_max_iterations: int, | ||
| analyzer_timeout_s: int, | ||
| ) -> Feedback: | ||
| """Produce a :class:`~microbots.auto_memory.data_models.Feedback` from callback results. | ||
| """Produce a :class:`~microbots.auto_memory.data_models.Feedback` via LogAnalysisBot. | ||
|
|
||
| Combines the stdout/stderr of every failed callback into a single log | ||
| file and hands it to a fresh | ||
| :class:`~microbots.bot.LogAnalysisBot.LogAnalysisBot` with the candidate | ||
| directory mounted read-only. The bot's final narrative answer is stored | ||
| as :attr:`Feedback.summary`; :attr:`Feedback.root_causes` is left empty | ||
| on the success path because the LLM emits a single diagnosis rather | ||
| than a discrete list. | ||
|
|
||
| Inspects every :class:`~microbots.auto_memory.callbacks.CallbackResult` | ||
| and assembles a structured failure report that the agent can read on | ||
| the next iteration. | ||
| On no failures, returns a minimal ``All callbacks passed.`` feedback | ||
| without invoking the bot. If the bot raises or fails to produce a | ||
| result, :attr:`Feedback.summary` falls back to a short | ||
| ``"<N> of <M> callback(s) failed: ..."`` string and | ||
| :attr:`Feedback.root_causes` contains the analyzer error. | ||
|
|
||
| In every non-passing case :attr:`Feedback.validator_failures` lists the | ||
| names of the callbacks that did not pass. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| callback_results : list[CallbackResult] | ||
| Results returned by | ||
| :meth:`~microbots.auto_memory.callbacks.ShellCallbackRunner.run_all`. | ||
| candidate_path : Path | ||
| Path to the candidate output for this iteration. Reserved for | ||
| future use (e.g. static analysis); not read directly at present. | ||
| Path to the candidate output for this iteration; mounted read-only | ||
| into the bot so it can correlate log entries with source. | ||
| iteration_idx : int | ||
| Zero-based index of the iteration that produced these results. | ||
| analyzer_model : str | ||
| LiteLLM model identifier for :class:`~microbots.bot.LogAnalysisBot.LogAnalysisBot`. | ||
| analyzer_max_iterations : int | ||
| Maximum bot iterations to run. | ||
| analyzer_timeout_s : int | ||
| Bot timeout in seconds. | ||
|
|
||
| Returns | ||
| ------- | ||
| Feedback | ||
| Structured summary with ``validator_failures``, ``root_causes``, | ||
| ``suggested_actions``, and a human-readable ``summary`` string. | ||
| and a human-readable ``summary`` string. | ||
| """ | ||
| failed = [r for r in callback_results if not r.passed] | ||
|
|
||
| validator_failures: list[str] = [r.spec.name for r in failed] | ||
| root_causes: list[str] = [] | ||
| suggested_actions: list[str] = [] | ||
|
|
||
| for result in failed: | ||
| if result.timed_out: | ||
| root_causes.append( | ||
| f"{result.spec.name}: timed out after {result.spec.timeout_s}s" | ||
| ) | ||
| suggested_actions.append( | ||
| f"Reduce runtime of {result.spec.name!r} or increase its timeout." | ||
| ) | ||
| else: | ||
| log_snippet = _read_tail(result.stderr_path) or _read_tail(result.stdout_path) | ||
| if log_snippet: | ||
| root_causes.append(f"{result.spec.name}: {log_snippet[:200]}") | ||
| else: | ||
| root_causes.append( | ||
| f"{result.spec.name}: exited with code {result.return_code}" | ||
| f" (expected {result.spec.expected_return_code})" | ||
| ) | ||
|
|
||
| if failed: | ||
| names = ", ".join(validator_failures) | ||
| summary = f"{len(failed)} of {len(callback_results)} callback(s) failed: {names}" | ||
| else: | ||
| summary = "All callbacks passed." | ||
| if not failed: | ||
| return Feedback(iteration_idx=iteration_idx, summary="All callbacks passed.") | ||
|
|
||
| summary = ( | ||
| f"{len(failed)} of {len(callback_results)} callback(s) failed: " | ||
| f"{', '.join(validator_failures)}" | ||
| ) | ||
|
|
||
| # LogAnalysisBot needs a directory for its read-only mount. | ||
| mount_folder = ( | ||
| candidate_path | ||
| if candidate_path.exists() and candidate_path.is_dir() | ||
| else candidate_path.parent | ||
| ) | ||
|
|
||
| combined_log = _write_combined_log(failed) | ||
| try: | ||
| bot = LogAnalysisBot( | ||
| model=analyzer_model, | ||
| folder_to_mount=str(mount_folder), | ||
| ) | ||
| bot_result = bot.run( | ||
| file_name=str(combined_log), | ||
| max_iterations=analyzer_max_iterations, | ||
| timeout_in_seconds=analyzer_timeout_s, | ||
| ) | ||
| except Exception as exc: | ||
| logger.warning("LogAnalysisBot invocation failed: %s", exc, exc_info=True) | ||
| return Feedback( | ||
| iteration_idx=iteration_idx, | ||
| summary=summary, | ||
| validator_failures=validator_failures, | ||
| root_causes=[f"LLM analyzer error: {exc}"], | ||
| ) | ||
| finally: | ||
| combined_log.unlink(missing_ok=True) | ||
|
|
||
| result = (bot_result.result or "").strip() | ||
| if bot_result.status and result: | ||
| # The LLM produces one narrative diagnosis, not a list of causes; | ||
| # store it as the summary and leave root_causes empty. | ||
| return Feedback( | ||
| iteration_idx=iteration_idx, | ||
| summary=result, | ||
| validator_failures=validator_failures, | ||
| ) | ||
|
|
||
| return Feedback( | ||
| iteration_idx=iteration_idx, | ||
| summary=summary, | ||
| root_causes=root_causes, | ||
| validator_failures=validator_failures, | ||
| suggested_actions=suggested_actions, | ||
| root_causes=[ | ||
| f"LLM analyzer did not produce a result: {bot_result.error or 'unknown'}" | ||
| ], | ||
| ) | ||
|
|
||
|
|
||
| def _read_tail(path: Path, max_bytes: int = _MAX_LOG_BYTES) -> str: | ||
| """Return the last *max_bytes* of *path* as a stripped string, or ``''`` if missing. | ||
| def _write_combined_log(failed: list[CallbackResult]) -> Path: | ||
| """Write a temp log file combining every failed callback's stdout/stderr. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| failed : list[CallbackResult] | ||
| Results of the callbacks that did not pass. | ||
|
|
||
| Returns | ||
| ------- | ||
| Path | ||
| Path to the newly created combined log file. Caller is responsible | ||
| for deleting it. | ||
| """ | ||
| with tempfile.NamedTemporaryFile( | ||
| mode="w", suffix=".log", delete=False, encoding="utf-8" | ||
| ) as fh: | ||
| for r in failed: | ||
| fh.write(f"\n===== callback: {r.spec.name} =====\n") | ||
| fh.write( | ||
| f"return_code={r.return_code} " | ||
| f"expected={r.spec.expected_return_code} " | ||
| f"timed_out={r.timed_out} " | ||
| f"duration_s={r.duration_s:.2f}\n" | ||
| ) | ||
| fh.write("--- stderr ---\n") | ||
| fh.write(_safe_read(r.stderr_path)) | ||
| fh.write("\n--- stdout ---\n") | ||
| fh.write(_safe_read(r.stdout_path)) | ||
| fh.write("\n") | ||
| return Path(fh.name) | ||
|
|
||
|
|
||
| def _safe_read(path: Path, max_bytes: int = _LOG_TAIL_BYTES) -> str: | ||
| """Return the tail of *path*'s text content, or a placeholder on I/O error. | ||
|
|
||
| Only the last ``max_bytes`` bytes are read to bound memory usage and the | ||
| downstream LLM analyzer context. When the file exceeds that cap a | ||
| ``"<truncated: showing last N bytes of M>\\n"`` marker is prepended so | ||
| the model knows the view is partial. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| path : Path | ||
| File to read from. | ||
| Filesystem path to read. | ||
| max_bytes : int, optional | ||
| Maximum number of bytes to read from the end of the file. | ||
| Maximum number of trailing bytes to return. Defaults to | ||
| ``_LOG_TAIL_BYTES``. A value ``<= 0`` disables the cap. | ||
|
|
||
| Returns | ||
| ------- | ||
| str | ||
| Decoded and stripped tail of the file, or an empty string if the | ||
| file cannot be opened. | ||
| The (possibly truncated) file contents decoded as UTF-8 (invalid | ||
| bytes replaced), or ``"<log unavailable>"`` if the file cannot be | ||
| read. | ||
| """ | ||
| try: | ||
| with path.open("rb") as fh: | ||
| fh.seek(0, 2) | ||
| size = fh.tell() | ||
| fh.seek(max(0, size - max_bytes)) | ||
| return fh.read().decode("utf-8", errors="replace").strip() | ||
| if max_bytes <= 0: | ||
| data = path.read_bytes() | ||
| truncated = False | ||
| total = len(data) | ||
| else: | ||
| size = path.stat().st_size | ||
| with path.open("rb") as fh: | ||
| if size > max_bytes: | ||
| fh.seek(size - max_bytes) | ||
| truncated = True | ||
| else: | ||
| truncated = False | ||
| data = fh.read() | ||
| total = size | ||
| except OSError: | ||
| return "" | ||
| return "<log unavailable>" | ||
|
|
||
| text = data.decode("utf-8", errors="replace") | ||
| if truncated: | ||
| return f"<truncated: showing last {len(data)} bytes of {total}>\n{text}" | ||
| return text | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.