Tighten Office document auto-handoff

Require explicit artifact, file, canvas, or format cues before turning response text into an Office artifact, while still allowing standalone deliverable-shaped drafts to open in the canvas. Add a same-turn guard so the response affordance does not duplicate documents already created with document_artifact, plus regression coverage for noisy long-document cases.

Alessandro committed Apr 27, 2026 at 03:08 UTC 8a6d47b23fb764635a431546343eb76fe9be1fdd
3 files changed +253 -18
plugins/_office/extensions/python/tool_execute_after/_20_document_response_affordance.py
+15 -1
@@ -11,6 +11,9 @@ from helpers.tool import Response
11 from plugins._office.helpers import document_affordance, wopi_store
12
13
14 +HANDOFF_CREATED_FLAG = "_office_document_handoff_created"
15 +
16 +
17 class DocumentResponseAffordance(Extension):
18 async def execute(
19 self,
@@ -18,12 +21,22 @@ class DocumentResponseAffordance(Extension):
21 response: Response | None = None,
22 **kwargs: Any,
23 ):
21 - if tool_name != "response" or not self.agent or response is None:
24 + if not self.agent or response is None:
25 + return
26 +
27 + if tool_name == "document_artifact":
28 + if (response.additional or {}).get("file_id"):
29 + self.agent.loop_data.params_persistent[HANDOFF_CREATED_FLAG] = True
30 + return
31 +
32 + if tool_name != "response":
33 return
34
35 tool = self.agent.loop_data.current_tool
36 if not tool:
37 return
38 + if self.agent.loop_data.params_persistent.get(HANDOFF_CREATED_FLAG):
39 + return
40
41 text = str(tool.args.get("text") or tool.args.get("message") or response.message or "").strip()
42 user_message = self.agent.last_user_message.content if self.agent.last_user_message else ""
@@ -51,6 +64,7 @@ class DocumentResponseAffordance(Extension):
64 content = json.dumps(payload, indent=2, ensure_ascii=False)
65
66 self.agent.hist_add_tool_result("document_artifact", content, **additional)
67 + self.agent.loop_data.params_persistent[HANDOFF_CREATED_FLAG] = True
68
69 display_path = display_workspace_path(doc["path"])
70 note = document_affordance.format_created_response(doc["basename"], display_path)
plugins/_office/helpers/document_affordance.py
+114 -17
@@ -11,16 +11,20 @@ MIN_EXPLICIT_ARTIFACT_CHARS = 240
11 MIN_EXPLICIT_ARTIFACT_WORDS = 35
12
13 CREATE_TERMS = {
14 - "write",
15 - "draft",
14 + "author",
15 + "build",
16 "compose",
17 + "convert",
18 "create",
19 + "draft",
20 + "format",
21 "generate",
22 + "make",
23 "prepare",
24 "produce",
21 - "make",
22 - "build",
23 - "author",
25 + "save",
26 + "turn",
27 + "write",
28 }
29
30 DOCUMENT_TERMS = {
@@ -65,6 +69,49 @@ PRESENTATION_TERMS = {
69 "slides",
70 }
71
72 +DELIVERABLE_TERMS = {
73 + "brief",
74 + "contract",
75 + "cv",
76 + "letter",
77 + "manual",
78 + "memo",
79 + "policy",
80 + "proposal",
81 + "report",
82 + "resume",
83 + "spec",
84 + "whitepaper",
85 +}
86 +
87 +EXPLICIT_FORMAT_TERMS = {
88 + "docx",
89 + "odt",
90 + "ods",
91 + "odp",
92 + "pptx",
93 + "xlsx",
94 +}
95 +
96 +HANDOFF_TERMS = {
97 + "artifact",
98 + "artifacts",
99 + "canvas",
100 + "downloadable",
101 + "editable",
102 + "in office",
103 + "office canvas",
104 + "open it",
105 + "open in office",
106 + "save it",
107 + "save this",
108 +}
109 +
110 +FILE_HANDOFF_TERMS = {
111 + "file",
112 + "files",
113 +}
114 +
115 CHAT_ONLY_TERMS = {
116 "answer in chat",
117 "in chat",
@@ -107,10 +154,12 @@ def decide_response_artifact(user_message: Any, response_text: str) -> ArtifactD
154 if looks_like_tool_or_status_response(response_text):
155 return None
156
110 - kind, fmt, explicit_artifact = infer_kind_and_format(lowered_user)
111 - if not explicit_artifact and not has_document_creation_intent(lowered_user):
157 + kind, fmt = infer_kind_and_format(lowered_user)
158 + intent = artifact_intent(lowered_user, response_text)
159 + if not intent:
160 return None
161
162 + explicit_artifact = intent == "explicit_handoff"
163 if not is_substantial(response_text, explicit_artifact):
164 return None
165
@@ -120,7 +169,7 @@ def decide_response_artifact(user_message: Any, response_text: str) -> ArtifactD
169 fmt=fmt,
170 title=title,
171 content=response_text,
123 - reason="explicit" if explicit_artifact else "document_intent",
172 + reason=intent,
173 )
174
175
@@ -151,17 +200,22 @@ def normalize_text(value: str) -> str:
200 return re.sub(r"\s+", " ", value.lower()).strip()
201
202
154 -def infer_kind_and_format(lowered_user: str) -> tuple[str, str, bool]:
155 - explicit = False
203 +def infer_kind_and_format(lowered_user: str) -> tuple[str, str]:
204 if has_any(lowered_user, PRESENTATION_TERMS):
157 - explicit = True
158 - return "presentation", "pptx", explicit
205 + return "presentation", "pptx"
206 if has_any(lowered_user, SPREADSHEET_TERMS):
160 - explicit = True
161 - return "spreadsheet", "xlsx", explicit
162 - if has_any(lowered_user, DOCUMENT_TERMS):
163 - explicit = True
164 - return "document", "docx", explicit
207 + return "spreadsheet", "xlsx"
208 + return "document", "docx"
209 +
210 +
211 +def artifact_intent(lowered_user: str, response_text: str) -> str | None:
212 + if not has_document_creation_intent(lowered_user):
213 + return None
214 + if has_explicit_handoff_signal(lowered_user):
215 + return "explicit_handoff"
216 + if has_any(lowered_user, DELIVERABLE_TERMS) and looks_like_standalone_artifact(response_text):
217 + return "document_intent"
218 + return None
219
220
221 def has_document_creation_intent(lowered_user: str) -> bool:
@@ -171,6 +225,28 @@ def has_document_creation_intent(lowered_user: str) -> bool:
225 )
226
227
228 +def has_explicit_handoff_signal(lowered_user: str) -> bool:
229 + if has_any(lowered_user, EXPLICIT_FORMAT_TERMS | HANDOFF_TERMS):
230 + return True
231 + if has_any(lowered_user, FILE_HANDOFF_TERMS) and has_any(
232 + lowered_user,
233 + DOCUMENT_TERMS | SPREADSHEET_TERMS | PRESENTATION_TERMS,
234 + ):
235 + return True
236 + if re.search(
237 + r"\b(?:convert|format|save|turn)\b(?:\W+\w+){0,8}?\W+(?:as|to|into)\s+"
238 + r"(?:a|an|the)?\s*(?:doc|document|spreadsheet|workbook|presentation|deck|slides|docx|xlsx|pptx)\b",
239 + lowered_user,
240 + ):
241 + return True
242 + return bool(re.search(
243 + r"\b(?:write|draft|compose|create|generate|prepare|produce|make|build|author|format)\b"
244 + r"(?:\s+(?:me|us|a|an|the|new|blank|editable|office|word|excel|powerpoint))*"
245 + r"\s+(?:doc|document|spreadsheet|workbook|presentation|deck|slides)\b",
246 + lowered_user,
247 + ))
248 +
249 +
250 def has_any(text: str, terms: set[str]) -> bool:
251 return any(re.search(rf"\b{re.escape(term)}\b", text) for term in terms)
252
@@ -192,6 +268,27 @@ def looks_like_tool_or_status_response(text: str) -> bool:
268 return False
269
270
271 +def looks_like_standalone_artifact(text: str) -> bool:
272 + lines = [line.strip() for line in text.splitlines() if line.strip()]
273 + if not lines or not title_from_response(text):
274 + return False
275 +
276 + heading_count = 0
277 + formal_marker_count = 0
278 + for line in lines[:40]:
279 + normalized = normalize_text(line)
280 + if re.match(r"^(#{1,4}\s+|\*\*.+\*\*$|[0-9]+[.)]\s+[A-Z])", line):
281 + heading_count += 1
282 + if re.match(
283 + r"^(executive summary|summary|purpose|scope|background|introduction|"
284 + r"recommendations?|conclusion|to:|from:|subject:|date:)\b",
285 + normalized,
286 + ):
287 + formal_marker_count += 1
288 +
289 + return heading_count >= 2 or formal_marker_count >= 2
290 +
291 +
292 def infer_title(user_text: str, response_text: str, kind: str) -> str:
293 response_title = title_from_response(response_text)
294 if response_title:
tests/test_office_document_affordance.py new
+124
@@ -0,0 +1,124 @@
1 +from __future__ import annotations
2 +
3 +import sys
4 +from pathlib import Path
5 +
6 +
7 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
8 +if str(PROJECT_ROOT) not in sys.path:
9 + sys.path.insert(0, str(PROJECT_ROOT))
10 +
11 +from plugins._office.helpers import document_affordance
12 +
13 +
14 +def substantial_text(prefix: str = "Here is the material.") -> str:
15 + paragraph = (
16 + "This section gives concrete context, constraints, tradeoffs, and next steps "
17 + "so the artifact has enough substance to be useful in a real collaboration. "
18 + )
19 + return f"{prefix}\n\n" + paragraph * 8
20 +
21 +
22 +def standalone_report() -> str:
23 + paragraph = (
24 + "The team should align the operating model, clarify ownership, and preserve "
25 + "a concise decision trail so execution remains calm, inspectable, and repeatable. "
26 + )
27 + return (
28 + "# Retention Report\n\n"
29 + "## Executive Summary\n"
30 + f"{paragraph * 4}\n\n"
31 + "## Recommendations\n"
32 + f"{paragraph * 4}"
33 + )
34 +
35 +
36 +def test_explicit_docx_request_creates_document_artifact():
37 + decision = document_affordance.decide_response_artifact(
38 + "Please create a DOCX report for the leadership review.",
39 + substantial_text(),
40 + )
41 +
42 + assert decision is not None
43 + assert decision.kind == "document"
44 + assert decision.fmt == "docx"
45 + assert decision.reason == "explicit_handoff"
46 +
47 +
48 +def test_explicit_spreadsheet_file_request_creates_spreadsheet_artifact():
49 + decision = document_affordance.decide_response_artifact(
50 + "Build an editable spreadsheet file for this budget.",
51 + substantial_text(),
52 + )
53 +
54 + assert decision is not None
55 + assert decision.kind == "spreadsheet"
56 + assert decision.fmt == "xlsx"
57 + assert decision.reason == "explicit_handoff"
58 +
59 +
60 +def test_convert_into_document_creates_document_artifact():
61 + decision = document_affordance.decide_response_artifact(
62 + "Convert this into a document.",
63 + substantial_text(),
64 + )
65 +
66 + assert decision is not None
67 + assert decision.kind == "document"
68 + assert decision.reason == "explicit_handoff"
69 +
70 +
71 +def test_long_document_topic_does_not_create_artifact_without_handoff_signal():
72 + decision = document_affordance.decide_response_artifact(
73 + "Write a detailed explanation of the document handoff implementation.",
74 + substantial_text(),
75 + )
76 +
77 + assert decision is None
78 +
79 +
80 +def test_long_policy_question_does_not_create_artifact_without_create_intent():
81 + decision = document_affordance.decide_response_artifact(
82 + "What should our remote-work policy say about async updates?",
83 + substantial_text(),
84 + )
85 +
86 + assert decision is None
87 +
88 +
89 +def test_office_as_workplace_topic_is_not_a_handoff_signal():
90 + decision = document_affordance.decide_response_artifact(
91 + "Write a memo about office etiquette.",
92 + substantial_text(),
93 + )
94 +
95 + assert decision is None
96 +
97 +
98 +def test_deliverable_request_needs_standalone_artifact_shape():
99 + decision = document_affordance.decide_response_artifact(
100 + "Draft a report about retention risks.",
101 + substantial_text(),
102 + )
103 +
104 + assert decision is None
105 +
106 +
107 +def test_deliverable_request_with_artifact_shape_creates_document_artifact():
108 + decision = document_affordance.decide_response_artifact(
109 + "Draft a report about retention risks.",
110 + standalone_report(),
111 + )
112 +
113 + assert decision is not None
114 + assert decision.kind == "document"
115 + assert decision.reason == "document_intent"
116 +
117 +
118 +def test_chat_only_instruction_blocks_even_explicit_file_request():
119 + decision = document_affordance.decide_response_artifact(
120 + "Create a DOCX report, but just answer in chat.",
121 + standalone_report(),
122 + )
123 +
124 + assert decision is None