diff --git a/evals/open-model-gym/agent-gym-report-2026-02-03.html b/evals/open-model-gym/agent-gym-report-2026-02-03.html deleted file mode 100644 index 5d3e63d292f0..000000000000 --- a/evals/open-model-gym/agent-gym-report-2026-02-03.html +++ /dev/null @@ -1,498 +0,0 @@ - - - - - Results - Agent Gym Workout - - - -
Agent Gym

Agent Gym Workout

-

- 27 passed / - 8 failed / - 35 total -

-

Agent Configurations: goose-full, opencode

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ModelAgent Configurationeveryday-app-automationfile-editingmulti-turn-edit
- ollama/glm-4.7-flash:latest -
- goose-full - (goose) -
-
- - 75.9s - log -
-
file_exists: workflow-log.md
file_not_empty: workflow-log.md
tool_called: slack_search_messages
tool_called: slack_get_user_info
tool_called: jira_create_issue
tool_called: calendar_create_event
-
-
- - 50.7s - log -
-
user.rs exists
display_name() added
has return type
email() preserved
first_name() preserved
main.rs exists
main.rs unchanged
cargo build
-
-
- - 53.3s - log -
-
email() added
compiles after turn 1
renamed to generated_email()
old email() removed
compiles after turn 2
-
- opencode - (opencode) -
-
- - 91.3s - log -
-
file_exists: workflow-log.md
file_not_empty: workflow-log.md
tool_called: slack_search_messages
tool_called: slack_get_user_info
tool_called: jira_create_issue
tool_called: calendar_create_event
-
-
- - 94.3s - log -
-
user.rs exists
display_name() added
has return type
email() preserved
first_name() preserved
main.rs exists
main.rs unchanged
cargo build
-
- ollama/gpt-oss:120b-cloud -
- goose-full - (goose) -
-
- - 35.0s - log -
-
file_exists: workflow-log.md
file_not_empty: workflow-log.md
tool_called: slack_search_messages
tool_called: slack_get_user_info
tool_called: jira_create_issue
tool_called: calendar_create_event
-
-
- - 10.6s - log -
-
user.rs exists
display_name() added
has return type
email() preserved
first_name() preserved
main.rs exists
main.rs unchanged
cargo build
-
-
- - 115.9s - log -
-
email() added
compiles after turn 1
renamed to generated_email()
old email() removed
compiles after turn 2
-
- opencode - (opencode) -
-
- - 57.6s - log -
-
file_exists: workflow-log.md
file_not_empty: workflow-log.md
tool_called: slack_search_messages
tool_called: slack_get_user_info
tool_called: jira_create_issue
tool_called: calendar_create_event
-
-
- - 67.3s - log -
-
user.rs exists
display_name() added
has return type
email() preserved
first_name() preserved
main.rs exists
main.rs unchanged
cargo build
-
- ollama/gpt-oss:20b -
- goose-full - (goose) -
-
- - 34.2s - log -
-
file_exists: workflow-log.md
file_not_empty: workflow-log.md
tool_called: slack_search_messages
tool_called: slack_get_user_info
tool_called: jira_create_issue
tool_called: calendar_create_event
-
-
- - 25.1s - log -
-
user.rs exists
display_name() added
has return type
email() preserved
first_name() preserved
main.rs exists
main.rs unchanged
cargo build
-
-
- - 32.1s - log -
-
email() added
compiles after turn 1
renamed to generated_email()
old email() removed
compiles after turn 2
-
- opencode - (opencode) -
-
- - 96.4s - log -
-
file_exists: workflow-log.md
file_not_empty: workflow-log.md
tool_called: slack_search_messages
tool_called: slack_get_user_info
tool_called: jira_create_issue
tool_called: calendar_create_event
-
-
- - 81.7s - log -
-
user.rs exists
display_name() added
has return type
email() preserved
first_name() preserved
main.rs exists
main.rs unchanged
cargo build
-
- ollama/kimi-k2.5:cloud -
- goose-full - (goose) -
-
- - 8.2s - log -
-
file_exists: workflow-log.md
file_not_empty: workflow-log.md
tool_called: slack_search_messages
tool_called: slack_get_user_info
tool_called: jira_create_issue
tool_called: calendar_create_event
-
-
- - 9.2s - log -
-
user.rs exists
display_name() added
has return type
email() preserved
first_name() preserved
main.rs exists
main.rs unchanged
cargo build
-
-
- - 8.7s - log -
-
email() added
compiles after turn 1
-
- opencode - (opencode) -
-
- - 40.3s - log -
-
file_exists: workflow-log.md
file_not_empty: workflow-log.md
tool_called: slack_search_messages
tool_called: slack_get_user_info
tool_called: jira_create_issue
tool_called: calendar_create_event
-
-
- - 57.7s - log -
-
user.rs exists
display_name() added
has return type
email() preserved
first_name() preserved
main.rs exists
main.rs unchanged
cargo build
-
- ollama/nemotron-3-nano:latest -
- goose-full - (goose) -
-
- - 255.3s - log -
-
file_exists: workflow-log.md
file_not_empty: workflow-log.md
tool_called: slack_search_messages
tool_called: slack_get_user_info
tool_called: jira_create_issue
tool_called: calendar_create_event
-
-
- - 124.9s - log -
-
user.rs exists
display_name() added
has return type
email() preserved
first_name() preserved
main.rs exists
main.rs unchanged
cargo build
-
-
- - 348.9s - log -
-
email() added
compiles after turn 1
-
- opencode - (opencode) -
-
- - 262.5s - log -
-
file_exists: workflow-log.md
file_not_empty: workflow-log.md
tool_called: slack_search_messages
tool_called: slack_get_user_info
tool_called: jira_create_issue
tool_called: calendar_create_event
-
-
- - 95.8s - log -
-
user.rs exists
display_name() added
has return type
email() preserved
first_name() preserved
main.rs exists
main.rs unchanged
cargo build
-
- anthropic/claude-opus-4-5-20251101 -
- goose-full - (goose) -
-
- - 29.4s - log -
-
file_exists: workflow-log.md
file_not_empty: workflow-log.md
tool_called: slack_search_messages
tool_called: slack_get_user_info
tool_called: jira_create_issue
tool_called: calendar_create_event
-
-
- - 22.7s - log -
-
user.rs exists
display_name() added
has return type
email() preserved
first_name() preserved
main.rs exists
main.rs unchanged
cargo build
-
-
- - 26.0s - log -
-
email() added
compiles after turn 1
renamed to generated_email()
old email() removed
compiles after turn 2
-
- opencode - (opencode) -
-
- - 36.3s - log -
-
file_exists: workflow-log.md
file_not_empty: workflow-log.md
tool_called: slack_search_messages
tool_called: slack_get_user_info
tool_called: jira_create_issue
tool_called: calendar_create_event
-
-
- - 54.9s - log -
-
user.rs exists
display_name() added
has return type
email() preserved
first_name() preserved
main.rs exists
main.rs unchanged
cargo build
-
- ollama/qwen3-coder:64k -
- goose-full - (goose) -
-
- - 64.0s - log -
-
file_exists: workflow-log.md
file_not_empty: workflow-log.md
tool_called: slack_search_messages
tool_called: slack_get_user_info
tool_called: jira_create_issue
tool_called: calendar_create_event
-
-
- - 48.1s - log -
-
user.rs exists
display_name() added
has return type
email() preserved
first_name() preserved
main.rs exists
main.rs unchanged
cargo build
-
-
- - 26.4s - log -
-
email() added
compiles after turn 1
-
- opencode - (opencode) -
-
- - 38.1s - log -
-
file_exists: workflow-log.md
file_not_empty: workflow-log.md
tool_called: slack_search_messages
tool_called: slack_get_user_info
tool_called: jira_create_issue
tool_called: calendar_create_event
-
-
- - 72.6s - log -
-
user.rs exists
display_name() added
has return type
email() preserved
first_name() preserved
main.rs exists
main.rs unchanged
cargo build
-
- -

Generated: 2026-02-03T07:29:55.899Z

- - - - - - - \ No newline at end of file diff --git a/evals/open-model-gym/config.yaml b/evals/open-model-gym/config.yaml index 148633215d5b..65b9f59cb4d5 100644 --- a/evals/open-model-gym/config.yaml +++ b/evals/open-model-gym/config.yaml @@ -48,13 +48,18 @@ runners: # stdio: # - node mcp-harness/dist/index.js - - name: goose-full + - name: goose type: goose bin: goose extensions: [developer, todo, skills, code_execution, extensionmanager] stdio: - node mcp-harness/dist/index.js + - name: goose-diet + type: goose + bin: ~/Downloads/goose-diet + extensions: [developer] + - name: opencode type: opencode bin: opencode @@ -69,6 +74,12 @@ runners: stdio: - node mcp-harness/dist/index.js + - name: pi-lean + type: pi + bin: pi + # Pi takes provider/model from the test matrix, not config + # MCP support via pi-mcp-adapter: `pi install npm:pi-mcp-adapter` + # ============================================================================= # Test Matrix # ============================================================================= @@ -80,6 +91,9 @@ matrix: - scenario: everyday-app-automation - scenario: file-editing + # Feature removal: all runners + - scenario: remove-feature + # Multi-turn: goose and pi only (opencode doesn't support session continuation) - scenario: multi-turn-edit - runners: [pi, goose-full] + runners: [pi, goose] diff --git a/evals/open-model-gym/mcp-harness/package-lock.json b/evals/open-model-gym/mcp-harness/package-lock.json index 4d49b127b608..b5a7da5c3cca 100644 --- a/evals/open-model-gym/mcp-harness/package-lock.json +++ b/evals/open-model-gym/mcp-harness/package-lock.json @@ -382,6 +382,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -586,6 +587,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -1156,6 +1158,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/evals/open-model-gym/suite/package-lock.json b/evals/open-model-gym/suite/package-lock.json index d55428255256..910d2e15c0a8 100644 --- a/evals/open-model-gym/suite/package-lock.json +++ b/evals/open-model-gym/suite/package-lock.json @@ -1,11 +1,11 @@ { - "name": "agent-scenario-suite", + "name": "agent-runner", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "agent-scenario-suite", + "name": "agent-runner", "version": "0.1.0", "dependencies": { "glob": "^11.0.0", diff --git a/evals/open-model-gym/suite/scenarios/remove-feature.yaml b/evals/open-model-gym/suite/scenarios/remove-feature.yaml new file mode 100644 index 000000000000..292815a98750 --- /dev/null +++ b/evals/open-model-gym/suite/scenarios/remove-feature.yaml @@ -0,0 +1,926 @@ +name: remove-feature +description: Remove a feature (insert_image) from a document editor tool module +prompt: | + Remove the `insert_image` feature entirely from this codebase. + It should be removed from docs.py and any references in doc_utils.py. + Make sure no traces remain: the operation literal, the ImageParams class, + the image_params parameter, the insert_image handler function, and any + validation/error handling specific to insert_image should all be removed. + +tags: + - file-editing + - feature-removal + - python + +setup: + docs.py: | + """Tools for interacting with documents.""" + + import json + from dataclasses import dataclass + from functools import partial + from typing import Annotated, Any, Literal + + from pydantic import Field + + from doc_utils import execute_request, execute_requests + + DOC_OPERATIONS = Literal[ + "get_document", + "insert_text", + "append_text", + "replace_text", + "delete_content", + "insert_table", + "update_table_cell", + "insert_table_row", + "insert_table_column", + "delete_table_row", + "delete_table_column", + "insert_image", + "format_existing_text", + ] + + RESPONSE_CHAR_LIMIT = 400000 + + + @dataclass + class FormatTextParams: + """Parameters for format_existing_text operation.""" + + search_text: str + foreground_color: str | None = None + background_color: str | None = None + bold: bool | None = None + italic: bool | None = None + underline: bool | None = None + strikethrough: bool | None = None + font_size: int | None = None + font_family: str | None = None + heading_level: int | None = None + link_url: str | None = None + list_type: str | None = None + + + @dataclass + class TableParams: + """Parameters for table operations. + + Used by operations: insert_table, update_table_cell, insert_table_row, + insert_table_column, delete_table_row, delete_table_column. + """ + + rows: int | None = Field(None, description="Number of rows for insert_table operation") + columns: int | None = Field(None, description="Number of columns for insert_table operation") + row_index: int | None = Field( + None, description="Row index (0-based) for update_table_cell, insert_table_row, and delete_table_row" + ) + column_index: int | None = Field( + None, description="Column index (0-based) for update_table_cell, insert_table_column, and delete_table_column" + ) + insert_below: bool = Field( + False, description="For insert_table_row: True to insert below the specified row, False to insert above" + ) + insert_right: bool = Field( + False, description="For insert_table_column: True to insert right of column, False to insert left" + ) + + + @dataclass + class ImageParams: + """Parameters for image insertion.""" + + image_url: str = Field(..., description="URL of the image to insert (must be publicly accessible)") + width: int | None = Field(None, description="Width of the image in points (PT)") + height: int | None = Field(None, description="Height of the image in points (PT)") + + + async def doc_tool( + document_id: str, + operation: DOC_OPERATIONS = "get_document", + text: Annotated[ + str, + Field( + description=( + "Text content to insert, append, or match for replacement. " + "For insert_text and append_text, Markdown formatting is supported. " + "For replace_text, this should be unformatted plain text." + ) + ), + ] = "", + replace_text: Annotated[ + str, + Field( + description=( + "New plain text that will replace all occurrences of the original text. " + "Only used for the replace_text operation." + ) + ), + ] = "", + start_position: Annotated[ + int | None, + Field( + description="Document index (1-based) for insert or delete operations.", + ge=1, + ), + ] = None, + end_position: Annotated[ + int | None, + Field( + description="Document index (1-based, exclusive) for delete_content", + ge=1, + ), + ] = None, + table_params: Annotated[ + TableParams | None, + Field( + None, + description="Parameters for table operations", + ), + ] = None, + image_params: ImageParams | None = None, + format_params: FormatTextParams | None = None, + ) -> str: + """Perform operations on an existing document. + + Supported operations: + - get_document: Returns document content + - insert_text: Inserts text at a specific position + - append_text: Appends text at the end of the document + - replace_text: Replaces all instances of text with replace_text + - delete_content: Deletes content between two positions + - insert_table: Creates a table with specified rows and columns + - update_table_cell: Updates content in a specific table cell + - insert_table_row: Inserts a row above or below the specified row + - insert_table_column: Inserts a column left or right of the specified column + - delete_table_row: Deletes the specified row from a table + - delete_table_column: Deletes the specified column from a table + - insert_image: Inserts an image from a URL at the specified position + - format_existing_text: Finds and applies formatting to text + """ + if not text and operation in ["insert_text", "append_text", "replace_text"]: + raise ValueError(f"text is required for {operation} operation") + + table_operations = [ + "insert_table", + "update_table_cell", + "insert_table_row", + "insert_table_column", + "delete_table_row", + "delete_table_column", + ] + if operation in table_operations and not table_params: + raise ValueError(f"table_params is required for {operation} operation") + + if operation == "insert_image" and not image_params: + raise ValueError("image_params is required for insert_image operation") + + if operation == "format_existing_text" and not format_params: + raise ValueError("format_params is required for format_existing_text operation") + + operation_handlers = { + "get_document": partial(read_document, document_id), + "insert_text": partial(insert_text, document_id, text, start_position), + "append_text": partial(append_text, document_id, text), + "replace_text": partial(replace_all_text, document_id, text, replace_text), + "delete_content": partial(delete_content, document_id, start_position, end_position), + "insert_table": partial( + insert_table, + document_id, + table_params.rows if table_params else None, + table_params.columns if table_params else None, + start_position, + ), + "update_table_cell": partial( + update_table_cell, + document_id, + table_params.row_index if table_params else None, + table_params.column_index if table_params else None, + text, + start_position, + ), + "insert_table_row": partial( + modify_table_structure, + document_id, + "insert_row", + table_params.row_index if table_params else None, + None, + table_params.insert_below if table_params else False, + False, + start_position, + ), + "insert_table_column": partial( + modify_table_structure, + document_id, + "insert_column", + None, + table_params.column_index if table_params else None, + False, + table_params.insert_right if table_params else False, + start_position, + ), + "delete_table_row": partial( + modify_table_structure, + document_id, + "delete_row", + table_params.row_index if table_params else None, + None, + False, + False, + start_position, + ), + "delete_table_column": partial( + modify_table_structure, + document_id, + "delete_column", + None, + table_params.column_index if table_params else None, + False, + False, + start_position, + ), + "insert_image": partial( + insert_image, + document_id, + image_params.image_url if image_params else None, + start_position, + image_params.width if image_params else None, + image_params.height if image_params else None, + ), + "format_existing_text": partial( + format_existing_text, + document_id, + format_params.search_text if format_params else None, + format_params.foreground_color if format_params else None, + format_params.background_color if format_params else None, + format_params.font_size if format_params else None, + format_params.font_family if format_params else None, + format_params.bold if format_params else None, + format_params.italic if format_params else None, + format_params.underline if format_params else None, + format_params.strikethrough if format_params else None, + format_params.heading_level if format_params else None, + format_params.link_url if format_params else None, + format_params.list_type if format_params else None, + ), + } + + if operation not in operation_handlers: + raise ValueError(f"Invalid operation: {operation}") + + response = await operation_handlers[operation]() + return json.dumps(response, indent=2) + + + def _extract_text_from_element(element: dict) -> str: + """Recursively pull text from paragraphs, tables, etc.""" + text_parts = "" + if "paragraph" in element: + paragraph_elements = element["paragraph"].get("elements", []) + for el in paragraph_elements: + if "textRun" in el: + content = el["textRun"]["content"] + url = el["textRun"].get("textStyle", {}).get("link", {}).get("url") + if url: + text_parts += f"[{content}]({url})" + else: + text_parts += content + elif "table" in element: + table_rows = element["table"].get("tableRows", []) + for row in table_rows: + for cell in row["tableCells"]: + for cell_content in cell["content"]: + text_parts += _extract_text_from_element(cell_content) + text_parts += "\n" + return text_parts + + + async def read_document(document_id: str) -> dict[str, Any]: + """Returns document content.""" + document = await execute_request("get", document_id, {}) + content = document.get("body", {}).get("content", []) + text = "".join(_extract_text_from_element(e) for e in content) + + result = {"content": text, "document_id": document_id} + + if len(json.dumps(result)) > RESPONSE_CHAR_LIMIT: + raise ValueError(f"Document {document_id} is too large to read.") + + return result + + + async def insert_text( + document_id: str, text: str, start_position: int | None + ) -> dict[str, Any]: + """Insert text at a specific index in a document.""" + if start_position is None: + raise ValueError("start_position is required for insert_text operation") + + request = {"insertText": {"location": {"index": start_position}, "text": text}} + await execute_request("update", document_id, request) + + from doc_utils import calculate_utf16_length + + inserted_length = calculate_utf16_length(text) + + return { + "message": f"Inserted text at position {start_position}", + "text": text, + "start_position": start_position, + "end_position": start_position + inserted_length, + } + + + async def append_text(document_id: str, text: str) -> dict[str, Any]: + """Append text to the end of a document.""" + end_index = await get_document_last_index(document_id) + if text[0] != "\n": + text = "\n" + text + response = await insert_text(document_id, text, end_index - 1) + response["message"] = "Appended text to the end of the document" + return response + + + async def replace_all_text( + document_id: str, text: str, replace_text: str + ) -> dict[str, str]: + """Replace all instances of a string in a document.""" + if not replace_text: + raise ValueError("replace_text parameter is required for replace_text operation") + + request = { + "replaceAllText": { + "containsText": {"text": text, "matchCase": True}, + "replaceText": replace_text, + } + } + + response = await execute_request("update", document_id, request) + occurrences = response.get("occurrencesChanged", 0) + if occurrences == 0: + return { + "message": f"No occurrences of text '{text}' found in the document.", + "text": text, + "replace_text": replace_text, + } + + return { + "message": f"Replaced {occurrences} occurrences of '{text}' with '{replace_text}'", + "text": text, + "replace_text": replace_text, + } + + + async def delete_content( + document_id: str, start_index: int | None, end_index: int | None + ) -> dict[str, Any]: + """Delete content between two positions.""" + if start_index is None or end_index is None: + raise ValueError("Both start_index and end_index are required for delete_content") + + request = { + "deleteContentRange": { + "range": {"startIndex": start_index, "endIndex": end_index} + } + } + await execute_request("update", document_id, request) + return { + "message": f"Deleted content between index {start_index} and {end_index}", + "start_index": start_index, + "end_index": end_index, + } + + + async def get_document_last_index(document_id: str) -> int: + """Get the last index of the document.""" + document = await execute_request("get", document_id, {}) + return document.get("body", {}).get("content", [])[-1].get("endIndex", 1) + + + async def insert_table( + document_id: str, rows: int | None, columns: int | None, start_position: int | None + ) -> str: + """Insert a table at a specific position.""" + if not rows or not columns: + raise ValueError("rows and columns are required for insert_table operation") + if start_position is None: + raise ValueError("start_position is required for insert_table operation") + + request = { + "insertTable": { + "rows": rows, + "columns": columns, + "location": {"index": start_position}, + } + } + await execute_request("update", document_id, request) + return f"Inserted {rows}x{columns} table at position {start_position}" + + + def _table_matches_position(element: dict, start_position: int | None) -> bool: + """Check if a table element contains the specified position.""" + if start_position is None: + return True + table_start = element.get("startIndex") + table_end = element.get("endIndex") + return table_start <= start_position < table_end + + + def _find_table_cell_range( + document: dict, row_index: int, column_index: int, start_position: int | None = None + ) -> tuple[int, int] | None: + """Find the content range within a table cell.""" + content = document.get("body", {}).get("content", []) + for element in content: + if "table" in element: + if not _table_matches_position(element, start_position): + continue + table = element["table"] + if row_index < len(table.get("tableRows", [])): + row = table["tableRows"][row_index] + if column_index < len(row.get("tableCells", [])): + cell = row["tableCells"][column_index] + cell_content = cell.get("content", []) + for cell_element in cell_content: + if "paragraph" in cell_element: + para_start = cell_element.get("startIndex") + para_end = cell_element.get("endIndex") + return (para_start, para_end) + cell_start = cell.get("startIndex") + cell_end = cell.get("endIndex") + if cell_start is not None and cell_end is not None: + return (cell_start + 1, cell_end - 1) + return None + + + def _find_table_start_index( + document: dict, row_index: int, column_index: int, start_position: int | None = None + ) -> int | None: + """Find the start index of a table element.""" + content = document.get("body", {}).get("content", []) + for element in content: + if "table" in element: + if not _table_matches_position(element, start_position): + continue + table = element["table"] + if row_index < len(table.get("tableRows", [])): + row = table["tableRows"][row_index] + if column_index < len(row.get("tableCells", [])): + return element.get("startIndex") + return None + + + async def update_table_cell( + document_id: str, + row_index: int | None, + column_index: int | None, + text: str, + start_position: int | None = None, + ) -> str: + """Update content in a specific table cell.""" + if row_index is None or column_index is None: + raise ValueError("row_index and column_index are required for update_table_cell") + + document = await execute_request("get", document_id, {}) + cell_range = _find_table_cell_range(document, row_index, column_index, start_position) + if not cell_range: + raise ValueError(f"Table cell at row {row_index}, col {column_index} not found") + + cell_start, cell_end = cell_range + + requests = [] + if cell_end > cell_start + 1: + requests.append( + {"deleteContentRange": {"range": {"startIndex": cell_start, "endIndex": cell_end - 1}}} + ) + + requests.append({"insertText": {"location": {"index": cell_start}, "text": text}}) + + await execute_requests(document_id, requests) + return f"Updated cell at row {row_index}, column {column_index}" + + + async def modify_table_structure( + document_id: str, + operation: str, + row_index: int | None = None, + column_index: int | None = None, + insert_below: bool = False, + insert_right: bool = False, + start_position: int | None = None, + ) -> str: + """Modify table structure by inserting or deleting rows/columns.""" + if operation in ["insert_row", "delete_row"] and row_index is None: + raise ValueError(f"row_index is required for {operation} operation") + if operation in ["insert_column", "delete_column"] and column_index is None: + raise ValueError(f"column_index is required for {operation} operation") + + document = await execute_request("get", document_id, {}) + + if operation in ["insert_row", "delete_row"]: + table_start = _find_table_start_index(document, row_index, 0, start_position) + if not table_start: + raise ValueError(f"Table row {row_index} not found") + location = {"tableStartLocation": {"index": table_start}, "rowIndex": row_index} + else: + table_start = _find_table_start_index(document, 0, column_index, start_position) + if not table_start: + raise ValueError(f"Table column {column_index} not found") + location = {"tableStartLocation": {"index": table_start}, "columnIndex": column_index} + + operation_map = { + "insert_row": "insertTableRow", + "insert_column": "insertTableColumn", + "delete_row": "deleteTableRow", + "delete_column": "deleteTableColumn", + } + request_key = operation_map[operation] + + if operation == "insert_row": + request = {request_key: {"tableCellLocation": location, "insertBelow": insert_below}} + message = f"Inserted row {'below' if insert_below else 'above'} row {row_index}" + elif operation == "insert_column": + request = {request_key: {"tableCellLocation": location, "insertRight": insert_right}} + message = f"Inserted column {'right of' if insert_right else 'left of'} column {column_index}" + elif operation == "delete_row": + request = {request_key: {"tableCellLocation": location}} + message = f"Deleted row {row_index}" + else: + request = {request_key: {"tableCellLocation": location}} + message = f"Deleted column {column_index}" + + await execute_request("update", document_id, request) + return message + + + async def insert_image( + document_id: str, + image_url: str | None, + start_position: int | None, + width: int | None, + height: int | None, + ) -> str: + """Insert an image from a URL at the specified position.""" + if not image_url: + raise ValueError("image_url is required for insert_image operation") + if start_position is None: + raise ValueError("start_position is required for insert_image operation") + + request = { + "insertInlineImage": { + "uri": image_url, + "location": {"index": start_position}, + } + } + + if width or height: + object_size = {} + if width: + object_size["width"] = {"magnitude": width, "unit": "PT"} + if height: + object_size["height"] = {"magnitude": height, "unit": "PT"} + request["insertInlineImage"]["objectSize"] = object_size + + await execute_request("update", document_id, request) + return f"Inserted image from {image_url} at position {start_position}" + + + async def format_existing_text( + document_id: str, + search_text: str | None, + foreground_color: str | None, + background_color: str | None, + font_size: int | None, + font_family: str | None, + bold: bool | None, + italic: bool | None, + underline: bool | None, + strikethrough: bool | None, + heading_level: int | None, + link_url: str | None, + list_type: str | None, + ) -> str: + """Find and apply formatting to all occurrences of text.""" + if not search_text: + raise ValueError("search_text is required for format_existing_text operation") + + document = await execute_request("get", document_id, {}) + + from doc_utils import build_text_style, find_text_positions + + positions = find_text_positions(document, search_text) + + if not positions: + return f"No occurrences of '{search_text}' found" + + text_style, fields = build_text_style( + foreground_color=foreground_color, + background_color=background_color, + font_size=font_size, + font_family=font_family, + bold=bold, + italic=italic, + underline=underline, + strikethrough=strikethrough, + link_url=link_url, + ) + + if not fields and not heading_level and not list_type: + raise ValueError("At least one formatting option must be specified") + + positions.sort(reverse=True) + + requests = [] + for start, end in positions: + range_dict = {"startIndex": start, "endIndex": end} + + if fields: + requests.append( + {"updateTextStyle": {"range": range_dict, "textStyle": text_style, "fields": fields}} + ) + + if heading_level: + if not (1 <= heading_level <= 6): + raise ValueError("heading_level must be between 1 and 6") + requests.append( + { + "updateParagraphStyle": { + "range": range_dict, + "paragraphStyle": {"namedStyleType": f"HEADING_{heading_level}"}, + "fields": "namedStyleType", + } + } + ) + + if list_type: + if list_type == "bullet": + preset = "BULLET_DISC_CIRCLE_SQUARE" + elif list_type == "numbered": + preset = "NUMBERED_DECIMAL_ALPHA_ROMAN" + else: + raise ValueError("list_type must be 'bullet' or 'numbered'") + requests.append({"createParagraphBullets": {"range": range_dict, "bulletPreset": preset}}) + + if not requests: + raise ValueError(f"No formatting requests generated for '{search_text}'.") + + await execute_requests(document_id, requests) + return f"Formatted {len(positions)} occurrences of '{search_text}'" + + doc_utils.py: | + """Utility functions for document operations.""" + + import re + from typing import Any + + + async def execute_request( + method: str, document_id: str, request: dict, **kwargs + ) -> dict[str, Any]: + """Execute a single document API request.""" + raise NotImplementedError("Stub: would call document backend") + + + async def execute_requests( + document_id: str, requests: list[dict], **kwargs + ) -> dict[str, Any]: + """Execute a batch of document API requests.""" + raise NotImplementedError("Stub: would call document backend") + + + def calculate_utf16_length(text: str) -> int: + """Calculate text length in UTF-16 code units.""" + return len(text.encode("utf-16-le")) // 2 + + + def hex_to_rgb(hex_color: str) -> dict: + """Convert hex color to RGB format (0.0-1.0 range).""" + hex_color = hex_color.lstrip("#") + + if len(hex_color) == 3: + hex_color = "".join([c * 2 for c in hex_color]) + + if not re.match(r"^[0-9A-Fa-f]{6}$", hex_color): + raise ValueError(f"Invalid hex color: {hex_color}") + + return { + "color": { + "rgbColor": { + "red": int(hex_color[0:2], 16) / 255.0, + "green": int(hex_color[2:4], 16) / 255.0, + "blue": int(hex_color[4:6], 16) / 255.0, + } + } + } + + + def build_text_style( + foreground_color: str | None = None, + background_color: str | None = None, + font_size: int | None = None, + font_family: str | None = None, + bold: bool | None = None, + italic: bool | None = None, + underline: bool | None = None, + strikethrough: bool | None = None, + link_url: str | None = None, + ) -> tuple[dict, str]: + """Build text style object and field mask from provided formatting options.""" + style = {} + fields = [] + + if foreground_color: + style["foregroundColor"] = hex_to_rgb(foreground_color) + fields.append("foregroundColor") + + if background_color: + style["backgroundColor"] = hex_to_rgb(background_color) + fields.append("backgroundColor") + + if font_size is not None: + style["fontSize"] = {"magnitude": font_size, "unit": "PT"} + fields.append("fontSize") + + if font_family: + style["weightedFontFamily"] = {"fontFamily": font_family} + fields.append("weightedFontFamily") + + if bold is not None: + style["bold"] = bold + fields.append("bold") + + if italic is not None: + style["italic"] = italic + fields.append("italic") + + if underline is not None: + style["underline"] = underline + fields.append("underline") + + if strikethrough is not None: + style["strikethrough"] = strikethrough + fields.append("strikethrough") + + if link_url: + style["link"] = {"url": link_url} + fields.append("link") + + return style, ",".join(fields) + + + def find_text_positions( + document: dict, search_text: str + ) -> list[tuple[int, int]]: + """Find all occurrences of text in a document with UTF-16 positions.""" + positions = [] + search_len = calculate_utf16_length(search_text) + + content = document.get("body", {}).get("content", []) + _find_text_in_elements(content, search_text, search_len, positions) + + return positions + + + def _find_text_in_elements( + elements: list[dict], search_text: str, search_len: int, positions: list + ): + """Recursively find text in document elements.""" + for element in elements: + if "paragraph" in element: + para_elements = element["paragraph"].get("elements", []) + for elem in para_elements: + if "textRun" in elem: + text_run = elem["textRun"] + content = text_run.get("content", "") + start_index = elem.get("startIndex", 0) + + idx = 0 + while True: + pos = content.find(search_text, idx) + if pos == -1: + break + prefix_len = calculate_utf16_length(content[:pos]) + match_start = start_index + prefix_len + match_end = match_start + search_len + positions.append((match_start, match_end)) + idx = pos + len(search_text) + + elif "table" in element: + table_rows = element["table"].get("tableRows", []) + for row in table_rows: + for cell in row.get("tableCells", []): + cell_content = cell.get("content", []) + _find_text_in_elements(cell_content, search_text, search_len, positions) + +validate: + # docs.py must still exist + - type: file_exists + path: docs.py + name: docs.py exists + + # doc_utils.py must still exist + - type: file_exists + path: doc_utils.py + name: doc_utils.py exists + + # insert_image removed from the operations literal + - type: file_not_matches + path: docs.py + regex: "insert_image" + name: insert_image removed from operations literal + + # ImageParams class removed + - type: file_not_matches + path: docs.py + regex: "ImageParams" + name: ImageParams class removed + + # image_params parameter removed + - type: file_not_matches + path: docs.py + regex: "image_params" + name: image_params parameter removed + + # insert_image function removed + - type: file_not_matches + path: docs.py + regex: "async def insert_image" + name: insert_image function removed + + # image_url reference removed + - type: file_not_matches + path: docs.py + regex: "image_url" + name: image_url references removed + + # insertInlineImage reference removed + - type: file_not_matches + path: docs.py + regex: "insertInlineImage" + name: insertInlineImage reference removed + + # Other operations still present + - type: file_contains + path: docs.py + pattern: "get_document" + name: get_document preserved + + - type: file_contains + path: docs.py + pattern: "insert_text" + name: insert_text preserved + + - type: file_contains + path: docs.py + pattern: "replace_text" + name: replace_text preserved + + - type: file_contains + path: docs.py + pattern: "insert_table" + name: insert_table preserved + + - type: file_contains + path: docs.py + pattern: "format_existing_text" + name: format_existing_text preserved + + - type: file_contains + path: docs.py + pattern: "async def doc_tool" + name: doc_tool function preserved + + - type: file_contains + path: docs.py + pattern: "FormatTextParams" + name: FormatTextParams preserved + + - type: file_contains + path: docs.py + pattern: "TableParams" + name: TableParams preserved + + # doc_utils.py should be untouched (no image references existed there) + - type: file_contains + path: doc_utils.py + pattern: "def build_text_style" + name: doc_utils build_text_style preserved + + - type: file_contains + path: doc_utils.py + pattern: "def find_text_positions" + name: doc_utils find_text_positions preserved + + - type: file_contains + path: doc_utils.py + pattern: "def calculate_utf16_length" + name: doc_utils calculate_utf16_length preserved + + # Python syntax check + - type: command_succeeds + command: "python3 -c \"import ast; ast.parse(open('docs.py').read())\"" + name: docs.py valid python syntax + + - type: command_succeeds + command: "python3 -c \"import ast; ast.parse(open('doc_utils.py').read())\"" + name: doc_utils.py valid python syntax