Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.250.225"
VERSION = "0.250.226"
IS_DEVELOPMENT = is_development_env_enabled()

SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
Expand Down
45 changes: 41 additions & 4 deletions application/single_app/functions_workflow_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@
WORKFLOW_CONVERSATION_ACCESS_ERROR = 'Workflow conversation not found or access denied.'
WORKFLOW_RUN_CANCELLED_MESSAGE = 'Workflow cancellation was requested.'
WORKFLOW_TASK_CONTEXT_MAX_CHARS = 12000
WORKFLOW_FILE_SYNC_CONTEXT_MAX_CHARS = 8000


class WorkflowRunCancelledError(BaseException):
Expand Down Expand Up @@ -6615,7 +6616,9 @@
if resolved_action.get('target_mode') != DOCUMENT_ACTION_TARGET_MODE_RECENT and not document_ids:
return {'workflow': workflow, 'citations': [], 'result_count': 0, 'document_count': 0, 'query': None}

query = str(workflow.get('task_prompt') or '').strip()
# Prefer the task's own instructions. task_prompt also carries injected File Sync context and

Check warning on line 6619 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
# previous-task output, which would otherwise become part of the search query itself.

Check warning on line 6620 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
query = str(workflow.get('task_search_query') or workflow.get('task_prompt') or '').strip()

Check warning on line 6621 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.

Check warning on line 6621 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
if not query:
return {'workflow': workflow, 'citations': [], 'result_count': 0, 'document_count': 0, 'query': None}

Expand Down Expand Up @@ -6995,6 +6998,21 @@
}


def _truncate_workflow_file_sync_context(value, max_chars=WORKFLOW_FILE_SYNC_CONTEXT_MAX_CHARS):
"""Bound the File Sync context block so a large sync cannot dominate the prompt."""

Check warning on line 7002 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
normalized = str(value or '').strip()
if len(normalized) <= max_chars:
return normalized

head_length = max_chars // 2
tail_length = max_chars - head_length
return (
f'{normalized[:head_length].rstrip()}\n\n'
'[File Sync context truncated]\n\n'
f'{normalized[-tail_length:].lstrip()}'
)


def _format_workflow_file_sync_context(file_sync_result):
if not isinstance(file_sync_result, dict) or not file_sync_result.get('enabled'):
return ''
Expand Down Expand Up @@ -7027,7 +7045,9 @@
)
if len(changed_documents) > 50:
lines.append(f'Additional changed documents omitted from prompt context: {len(changed_documents) - 50}')
return '\n'.join(lines)
# Truncate here rather than at the injection site so the conversation transcript and the

Check warning on line 7048 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
# prompt the model actually receives stay identical.

Check warning on line 7049 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
return _truncate_workflow_file_sync_context('\n'.join(lines))


def _apply_file_sync_changed_documents_to_action(action_config, changed_document_ids, group_ids, public_workspace_ids):
Expand Down Expand Up @@ -7057,6 +7077,12 @@
file_sync_context = _format_workflow_file_sync_context(file_sync_result)
if file_sync_context:
prepared_workflow['task_prompt'] = f"{workflow.get('task_prompt', '')}\n\n{file_sync_context}".strip()
# Task-based workflows overwrite task_prompt per task, so the context has to travel on

Check warning on line 7080 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
# its own key for _build_workflow_task_execution_workflow() to inject it.
prepared_workflow['file_sync_prompt_context'] = file_sync_context

Check warning on line 7082 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
# Keep an un-augmented query source for the legacy no-tasks path, whose document search

Check warning on line 7083 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
# would otherwise use the whole changed-document manifest as its search query.
prepared_workflow.setdefault('task_search_query', str(workflow.get('task_prompt') or '').strip())

config = _get_workflow_file_sync_config(workflow)
changed_document_ids = list(file_sync_result.get('changed_document_ids') or [])
Expand Down Expand Up @@ -9103,17 +9129,26 @@
return {'type': DOCUMENT_ACTION_TYPE_NONE}


def _build_workflow_task_execution_workflow(workflow, task, previous_reply='', include_document_action=False):
def _build_workflow_task_execution_workflow(
workflow,
task,
previous_reply='',
include_document_action=False,
include_file_sync_context=False,
):
task = task if isinstance(task, dict) else {}
prepared_workflow = dict(workflow or {})
task_instructions = str(task.get('instructions') or '').strip()
# Captured before any context blocks are appended so document search queries stay scoped to
# what this task actually asks for.
task_search_query = task_instructions
file_sync_context = str(workflow.get('file_sync_prompt_context') or '').strip()
task_document_action = _resolve_workflow_task_document_action(
workflow,
task,
include_document_action=include_document_action,
)
if include_document_action and file_sync_context:
if include_file_sync_context and file_sync_context:
task_instructions = (
f'{task_instructions}\n\n'
'[Workflow input context]\n'
Expand All @@ -9129,6 +9164,7 @@
).strip()

prepared_workflow['task_prompt'] = task_instructions
prepared_workflow['task_search_query'] = task_search_query
prepared_workflow['active_task'] = {
'id': str(task.get('id') or '').strip(),
'name': str(task.get('name') or '').strip(),
Expand Down Expand Up @@ -9541,6 +9577,7 @@
task,
previous_reply=previous_reply,
include_document_action=task_index == 0,
include_file_sync_context=task_index == 0,
)
attempt_workflow, runner_audit = _resolve_workflow_task_runner(
prepared_workflow,
Expand Down
206 changes: 206 additions & 0 deletions docs/explanation/fixes/WORKFLOW_FILE_SYNC_PROMPT_CONTEXT_FIX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
# Workflow File Sync Prompt Context Fix

**Fixed in version: 0.250.226**
**Issue:** [microsoft/simplechat#1285](https://github.com/microsoft/simplechat/issues/1285)

## Issue

File Sync's **prompt context** — the block that tells a workflow *what actually changed* — never
reached the model in any task-based workflow. Since the workflow builder always creates at least
one task, that was effectively every workflow.

The failure was silent and actively misleading: the context **was** written into the
conversation's user message, so the run transcript displayed the changed-document list as though
the model had received it. In practice the model was given only the raw task instructions and
typically replied that it had no information about any documents.

### What File Sync is supposed to do

Before a workflow runs, File Sync scans the configured sources and hands the workflow two separate
things:

1. **Prompt context** — built by `_format_workflow_file_sync_context()`: the sync counts
(`Scanned / Created / Updated / Unchanged / Skipped / Failed`) and a numbered list of each new
or changed document (`relative_path`, `action`, `document_id`, `source_name`). This is what
makes instructions like *"summarize the documents that changed since the last run"* work.
2. **Document targets** — when **Use changed documents** is enabled and the document action is
Analyze, the action is repointed at exactly the changed document ids.

Item 2 worked. Item 1 did not.

### Who was affected

- **Monitor File Sync Changes** workflows — an entire trigger type whose purpose is reacting to
what changed — unless they happened to use Analyze with **Use changed documents**.
- Any workflow using `Search` or `No document action` got nothing at all from File Sync.
- Any workflow with **Use changed documents** disabled got nothing.

## Root Cause

`_apply_file_sync_context_to_workflow()` appended the context to the **workflow-level**
`task_prompt`. `_build_workflow_task_execution_workflow()` expected it on a **different key**, then
overwrote `task_prompt` with the task's own instructions:

```python
file_sync_context = str(workflow.get('file_sync_prompt_context') or '').strip() # always ''
if include_document_action and file_sync_context: # never fired
task_instructions = f'{task_instructions}\n\n[Workflow input context]\n{file_sync_context}'
...
prepared_workflow['task_prompt'] = task_instructions # discarded the appended context
```

`file_sync_prompt_context` was read in exactly one place and written nowhere. The producer line was
added in `79148c84` ("Add stepped workflow builder and task sequences") alongside the consumer:

```diff
if file_sync_context:
prepared_workflow['task_prompt'] = f"{workflow.get('task_prompt', '')}\n\n{file_sync_context}".strip()
+ prepared_workflow['file_sync_prompt_context'] = file_sync_context
```

It was later lost in a merge resolution on a long-lived branch (the
`fix/1031-tabular-row-orchestration-scale` / PR #1145 lineage). The consumer survived; the producer
did not.

### Why no test caught it

`functional_tests/test_workflow_task_sequence.py` hand-injected `file_sync_prompt_context` directly
into the workflow dict and then asserted that `[Workflow input context]` appeared in the dispatched
prompt. It exercised the consumer with a value production never supplied, so it passed while the
feature was broken.

## Technical Details

### Files Modified

| File | Change |
|---|---|
| `application/single_app/functions_workflow_runner.py` | Restored the producer, bounded the context, gave the search query a clean source, made the first-task gate explicit |
| `application/single_app/config.py` | `VERSION` `0.250.225` → `0.250.226` |
| `functional_tests/test_workflow_file_sync_prompt_context.py` | **New** producer-to-consumer coverage |
| `functional_tests/test_workflow_task_sequence.py` | Builds the context through the real producer instead of injecting the key |

### Code Changes

**1. Restored the producer.**

```python
if file_sync_context:
prepared_workflow['task_prompt'] = f"{workflow.get('task_prompt', '')}\n\n{file_sync_context}".strip()
prepared_workflow['file_sync_prompt_context'] = file_sync_context
prepared_workflow.setdefault('task_search_query', str(workflow.get('task_prompt') or '').strip())
```

**2. Gave the document search query a clean source.**

This was the blocking discovery. `_prepare_workflow_search_context()` used `workflow['task_prompt']`
**verbatim as the Azure AI Search query**, and that single `query` value fed all four search call
sites. Restoring the injection without this change would have turned a Search task's query into 50
lines of file paths and sync counters.

The prepared workflow now carries `task_search_query`, captured from the task's instructions
*before* any context blocks are appended:

```python
task_search_query = task_instructions # captured before File Sync / previous-output injection
...
prepared_workflow['task_search_query'] = task_search_query
```

```python
query = str(workflow.get('task_search_query') or workflow.get('task_prompt') or '').strip()
```

The fallback to `task_prompt` means every caller without the key behaves exactly as before.

This also corrects a related case: after per-task workspace documents shipped in
[#1284](https://github.com/microsoft/simplechat/pull/1284), a task other than the first can carry a
Search action, and its query previously included the entire previous-task reply. Search queries are
now scoped to the task's own instructions.

**3. Made the first-task gate explicit.**

The consumer was gated on `include_document_action`, which used to mean "task 1". After #1284 that
flag means "legacy record with no task-level document action", so it no longer expressed the
intent. A dedicated parameter now carries it:

```python
def _build_workflow_task_execution_workflow(
workflow, task, previous_reply='', include_document_action=False, include_file_sync_context=False,
):
```

```python
prepared_workflow = _build_workflow_task_execution_workflow(
workflow, task,
previous_reply=previous_reply,
include_document_action=task_index == 0,
include_file_sync_context=task_index == 0,
)
```

Later tasks receive the information indirectly, through task one's response, which is already
chained forward as bounded context.

**4. Bounded the context block.**

`WORKFLOW_FILE_SYNC_CONTEXT_MAX_CHARS = 8000` with head/tail truncation and a
`[File Sync context truncated]` marker, mirroring the existing treatment of previous-task output.
It is applied inside `_format_workflow_file_sync_context()` rather than at the injection site, so
the conversation transcript and the prompt the model receives stay identical — the mismatch between
those two is exactly what made this bug invisible.

### Testing

`functional_tests/test_workflow_file_sync_prompt_context.py` covers:

- The producer publishes `file_sync_prompt_context`, and attaches nothing when File Sync is off.
- The first task's prompt carries `[Workflow input context]` and the changed-document list; later
tasks do not.
- The legacy no-tasks path still carries the context on `task_prompt`.
- Search queries use the task's own instructions, with no File Sync block and no previous-task
output, for both the task path and the legacy path.
- The truncation notice appears past the cap and not below it, including a realistic 50-document
sync with deeply nested paths.
- A run with nothing changed still tells the first task that nothing changed.
- **Use changed documents** targeting is unaffected.

Both this file and the updated `test_workflow_task_sequence.py` were verified by mutation: removing
the restored producer line makes 4 of 8 and 1 of 10 tests fail respectively, so the regression
cannot silently return.

## Validation

### Before

| Scenario | Result |
|---|---|
| File Sync workflow, `No document action`, instructions ask what changed | Model has no information about any documents |
| Conversation transcript | Shows the full changed-document list, implying the model received it |
| Search task query | The task instructions only, because the context never arrived |

### After

| Scenario | Result |
|---|---|
| File Sync workflow, `No document action`, instructions ask what changed | First task's prompt contains the sync counts and changed-document list |
| Conversation transcript | Matches what the model received |
| Search task query | The task's own instructions, free of File Sync context and previous-task output |
| Very large sync | Context bounded at 8000 characters with an explicit truncation notice |
| Nothing changed | First task is told "No new or changed synced documents were detected." |

### Regression Testing

The full `test_workflow*` suite was run against a clean `git archive` export of the base commit.
Both baseline and branch produce the same 21 pre-existing failures, and per-file output diffs
contain only traceback path differences. Route policy tests pass, and the personal and group
document picker harnesses from #1284 still pass.

## Related

- Issue: [microsoft/simplechat#1285](https://github.com/microsoft/simplechat/issues/1285)
- Preceding work: [#1284](https://github.com/microsoft/simplechat/pull/1284), which introduced
per-task workspace documents and changed the meaning of `include_document_action`
- Feature: `docs/explanation/features/WORKFLOW_PER_TASK_WORKSPACE_DOCUMENTS.md`
- Tests: `functional_tests/test_workflow_file_sync_prompt_context.py`,
`functional_tests/test_workflow_task_sequence.py`
1 change: 1 addition & 0 deletions docs/explanation/fixes/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ category: Version History
- [Route Authentication Audit Findings Fix](ROUTE_AUTHENTICATION_AUDIT_FINDINGS_FIX.md)
- [Cosmos Container Throughput Deployer Fix](COSMOS_CONTAINER_THROUGHPUT_DEPLOYER_FIX.md)
- [Workflow Task Document Picker Fix](WORKFLOW_TASK_DOCUMENT_PICKER_FIX.md)
- [Workflow File Sync Prompt Context Fix](WORKFLOW_FILE_SYNC_PROMPT_CONTEXT_FIX.md)
16 changes: 16 additions & 0 deletions docs/explanation/release_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/).

### **(v0.250.226)**

#### Bug Fixes

* **File Sync Now Tells the Workflow What Changed**
* File Sync builds a summary of each run — the scan counts plus every new or changed document — but that summary never reached the model in any workflow that uses tasks, which is every workflow the builder creates.
* The failure was silent and misleading: the summary *was* written into the conversation, so the transcript showed the changed-document list as though the model had received it. In practice the model saw only the raw task instructions and usually replied that it knew nothing about any documents.
* This hit **Monitor File Sync Changes** workflows hardest, along with any workflow using Search or no document action, or with **Use changed documents** turned off. The first task in the sequence now receives the summary, and later tasks get it through the first task's response.
* The summary is also bounded now, with a clear truncation notice, so a very large sync cannot crowd out the actual instructions.
* (Ref: #1285, `functions_workflow_runner.py`, File Sync prompt context)

* **Document Search Queries Are No Longer Diluted by Injected Context**
* A workflow's document search used the entire task prompt as its search query, including the File Sync summary and the previous task's full response. A search for "find the renewal clause" could end up querying 50 lines of file paths.
* Search queries now use the task's own instructions. Retrieved content and context still reach the model exactly as before — only the query is scoped.
* (Ref: #1285, `functions_workflow_runner.py`, workflow document search)

### **(v0.250.225)**

#### New Features
Expand Down
Loading
Loading