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