diff --git a/.github/instructions/workshop.instructions.md b/.github/instructions/workshop.instructions.md
index 43994cb..2c58e73 100644
--- a/.github/instructions/workshop.instructions.md
+++ b/.github/instructions/workshop.instructions.md
@@ -45,18 +45,22 @@ and `python -m pytest .workshop/scripts/tests`.
add them to later `.workshop/step_files/NN`.
- **Keep it teaching-quality.** Never minify code in `.workshop/step_files/` or
`.workshop/solutions/` — keep it clean and expanded (KISS).
-- **Links and images in step docs are repo-root-relative.** Step docs render
- into the participant's **root** `README.md`, so author relative links and
- image paths as they resolve *from the repo root* — e.g.
- `.workshop/docs/assets/NN-*.png` and `.workshop/solutions/NN-*/`, not
- `docs/assets/...` or `../assets/...`. Consequence: viewing a source step doc
- directly on GitHub (under `.workshop/docs/steps/`) shows those relative links
- as broken — that is expected; the **rendered `README.md` is the canonical
- artifact**. For links that must leave the docs, use absolute URLs:
- `https://github.com/{{OWNER}}/{{REPO}}/...` when the link must target the
- participant's **own** instance (e.g. their Actions workflows), and a hardcoded
- `https://github.com/Azure-Samples/foundry-hosted-agents-workshop/...` when it must reach
- the **template/upstream** repo (e.g. the workshop-feedback issue template).
+- **Links and images in step docs and partials are source-relative.** Author
+ relative links and image paths **relative to the folder the source file lives
+ in**, so they resolve when the file is viewed directly on GitHub. From a step
+ doc under `.workshop/docs/steps/`, that means `../assets/NN-*.png`,
+ `../../solutions/NN-*/`, and `NN-intro.md#anchor` for a sibling step; from a
+ partial under `.workshop/docs/partials/`, use `../steps/00-intro.md#anchor`.
+ The renderer (`render_readme.py`) rebases these to repo-root-relative when it
+ inlines them into the root `README.md`, so both the source doc and the rendered
+ README have working links. `lint_steps.py` check **K** rejects root-relative
+ links and any link/image whose target doesn't exist. For links that must leave
+ the docs, use absolute URLs: `https://github.com/{{OWNER}}/{{REPO}}/...` when
+ the link must target the participant's **own** instance (e.g. their Actions
+ workflows), and a hardcoded
+ `https://github.com/Azure-Samples/foundry-hosted-agents-workshop/...` when it
+ must reach the **template/upstream** repo (e.g. the workshop-feedback issue
+ template).
## Before you open a PR
diff --git a/.workshop/docs/partials/_header.md b/.workshop/docs/partials/_header.md
index 26c34cd..17be254 100644
--- a/.workshop/docs/partials/_header.md
+++ b/.workshop/docs/partials/_header.md
@@ -14,4 +14,4 @@ Built on the upstream [foundry-samples](https://github.com/microsoft-foundry/fou
-If something looks broken see [Troubleshooting](.workshop/docs/steps/00-intro.md#troubleshooting).
+If something looks broken see [Troubleshooting](../steps/00-intro.md#troubleshooting).
diff --git a/.workshop/docs/partials/_push_to_advance.md b/.workshop/docs/partials/_push_to_advance.md
index a8059a2..70a9264 100644
--- a/.workshop/docs/partials/_push_to_advance.md
+++ b/.workshop/docs/partials/_push_to_advance.md
@@ -19,6 +19,6 @@ After the **Advance workshop on push to main** Action finishes, run **`git pull`
> Each push to `main` advances the workshop by exactly **one** step, so push once — when this step is done.
-> **Prefer to stay local?** Run `python .workshop/scripts/advance_step.py --expected-current-step {{CURRENT_STEP}} --auto-commit` (or `make advance`) instead. That advances locally and records it in the same commit, so your next push won't advance again. See [Working fully locally](.workshop/docs/steps/00-intro.md#5-working-fully-locally-no-github-actions).
+> **Prefer to stay local?** Run `python .workshop/scripts/advance_step.py --expected-current-step {{CURRENT_STEP}} --auto-commit` (or `make advance`) instead. That advances locally and records it in the same commit, so your next push won't advance again. See [Working fully locally](../steps/00-intro.md#5-working-fully-locally-no-github-actions).
Made a mistake? Use the [Reset workshop](https://github.com/{{OWNER}}/{{REPO}}/actions/workflows/reset-workshop.yml) workflow, or run `python .workshop/scripts/advance_step.py --reset --auto-commit` locally.
diff --git a/.workshop/docs/partials/_start_button.md b/.workshop/docs/partials/_start_button.md
index 27bd811..adf1286 100644
--- a/.workshop/docs/partials/_start_button.md
+++ b/.workshop/docs/partials/_start_button.md
@@ -15,6 +15,6 @@ From Step {{NEXT_STEP_NUMBER}} onward you don't click a button to advance — yo
> 💡 **Button returns 404?** Your repo's one-time **Initialize workshop** Action hasn't run yet. Open the **Actions** tab, run **Initialize workshop → Run workflow**, then refresh this page.
-> **Prefer to stay local?** Run `python .workshop/scripts/advance_step.py --expected-current-step {{CURRENT_STEP}} --auto-commit` (or `make advance`) instead of clicking the button. See [Working fully locally](.workshop/docs/steps/00-intro.md#5-working-fully-locally-no-github-actions) for the full local flow.
+> **Prefer to stay local?** Run `python .workshop/scripts/advance_step.py --expected-current-step {{CURRENT_STEP}} --auto-commit` (or `make advance`) instead of clicking the button. See [Working fully locally](../steps/00-intro.md#5-working-fully-locally-no-github-actions) for the full local flow.
Made a mistake? Use the [Reset workshop](https://github.com/{{OWNER}}/{{REPO}}/actions/workflows/reset-workshop.yml) workflow, or run `python .workshop/scripts/advance_step.py --reset --auto-commit` locally.
diff --git a/.workshop/docs/steps/01-basic.md b/.workshop/docs/steps/01-basic.md
index 38fbf0d..84de7d2 100644
--- a/.workshop/docs/steps/01-basic.md
+++ b/.workshop/docs/steps/01-basic.md
@@ -20,7 +20,7 @@
In this step you **complete** the one small edit called out below (TravelBuddy's instructions in `main.py`) — you don't create the files from scratch, and the two YAML files are ready to use as-is. The workshop is **incremental**: when you advance, the next step's files are laid on top of your `travel_assistant/` folder. Files from earlier steps that the next step doesn't touch stay exactly as they are — nothing is deleted. Files the next step ships (for example an updated `main.py`) are refreshed to that step's version, and your current work is backed up under `.workshop_instance/workshop_backups/step-/` first, so you can always recover your own wording.
-> **Before you start:** make sure you completed the setup in Step 0 — [Install the tools you'll need](.workshop/docs/steps/00-intro.md#install-the-tools-youll-need) and [Set up your local environment](.workshop/docs/steps/00-intro.md#set-up-your-local-environment-one-time). If `python .workshop/scripts/preflight.py --step 1` is green, you're ready.
+> **Before you start:** make sure you completed the setup in Step 0 — [Install the tools you'll need](00-intro.md#install-the-tools-youll-need) and [Set up your local environment](00-intro.md#set-up-your-local-environment-one-time). If `python .workshop/scripts/preflight.py --step 1` is green, you're ready.
## Concept (5-min read)
@@ -98,7 +98,7 @@ Open `travel_assistant/agent.manifest.yaml` and read through it — the inline c
**What happens at run/deploy time:** locally, this process serves an OpenAI-compatible Responses endpoint on `http://localhost:8088`. After deployment, Foundry starts the same code as the hosted container entry point.
-Open `travel_assistant/main.py` and complete the `TODO`. If you get stuck, the finished file is in [`.workshop/solutions/01-basic/`](.workshop/solutions/01-basic/).
+Open `travel_assistant/main.py` and complete the `TODO`. If you get stuck, the finished file is in [`.workshop/solutions/01-basic/`](../../solutions/01-basic/).
`ResponsesHostServer` is the hosted-agent contract: when started locally it serves an OpenAI-compatible Responses endpoint on `http://localhost:8088`; when packaged and deployed to Foundry it becomes the container entry point. The same code runs in both places.
@@ -227,7 +227,7 @@ You can run and deploy TravelBuddy two ways: with the [**Azure Developer CLI** (
Prefer a UI? With the local agent still running, open the **Agent Inspector** from the Foundry Toolkit (Command Palette → **Foundry Toolkit: Open Agent Inspector**, or the **Agent Inspector** entry under **Developer Tools**). It connects to `http://localhost:8088` and lets you chat with TravelBuddy and watch the streamed Responses events.
- 
+ 
5. **Deploy to Foundry**. Subsequent workshop steps only need `azd deploy`:
@@ -247,7 +247,7 @@ You can run and deploy TravelBuddy two ways: with the [**Azure Developer CLI** (
Prefer a UI? Open the **Hosted Agent Playground** from the Foundry Toolkit (under **Developer Tools** → **Build** → **Hosted Agent Playground**). Pick your deployed agent and version, then chat with TravelBuddy and inspect session details, logs, and traces directly in VS Code.
- 
+ 
When you select **Hosted Agent Playground** in the Foundry Toolkit's **Developer Tools**, you'll be prompted to sign in. If the interactive sign-in doesn't complete, cancel it and choose the **device code** flow instead. Once signed in, select the Foundry project that hosts your deployed agent.
@@ -280,7 +280,7 @@ Whichever option you picked, try a few prompts:
## Solution
-> If you get stuck: [`.workshop/solutions/01-basic/`](.workshop/solutions/01-basic/)
+> If you get stuck: [`.workshop/solutions/01-basic/`](../../solutions/01-basic/)
## Upstream sample
diff --git a/.workshop/docs/steps/02-tools.md b/.workshop/docs/steps/02-tools.md
index f8bf1ba..0fd1abb 100644
--- a/.workshop/docs/steps/02-tools.md
+++ b/.workshop/docs/steps/02-tools.md
@@ -191,7 +191,7 @@ Then update the `Agent(...)` call. **Keep your Step 1 instructions exactly as th
)
```
-That's the whole change: the `from tools import ...` line and the `tools=[...]` argument do the wiring; the appended instructions just tell the model when to reach for the tools. Everything else in `main.py` is unchanged from Step 1. If you get stuck, the finished file is in [`.workshop/solutions/02-tools/`](.workshop/solutions/02-tools/).
+That's the whole change: the `from tools import ...` line and the `tools=[...]` argument do the wiring; the appended instructions just tell the model when to reach for the tools. Everything else in `main.py` is unchanged from Step 1. If you get stuck, the finished file is in [`.workshop/solutions/02-tools/`](../../solutions/02-tools/).
### 3. Update the metadata in `travel_assistant/agent.manifest.yaml`
@@ -346,7 +346,7 @@ For the comparison prompt, the model may call `get_weather` once per city.
## Solution
-> If you get stuck: [`.workshop/solutions/02-tools/`](.workshop/solutions/02-tools/)
+> If you get stuck: [`.workshop/solutions/02-tools/`](../../solutions/02-tools/)
## Upstream sample
diff --git a/.workshop/docs/steps/03-mcp.md b/.workshop/docs/steps/03-mcp.md
index 28bd716..e6c3f27 100644
--- a/.workshop/docs/steps/03-mcp.md
+++ b/.workshop/docs/steps/03-mcp.md
@@ -113,7 +113,7 @@ Then **keep your Step 2 instructions exactly as they are** and append one MCP se
tools=tools, # <-- the list you just extended above
```
-That's the whole code change. `client.get_mcp_tool(...)` reads the label and URL from the environment (the same values you just added to `.env`) and hands the agent a remote tool. Everything else in `main.py` — the `FoundryChatClient` setup, the three function tools, `default_options={"store": False}`, and `ResponsesHostServer(agent).run()` — is unchanged from Step 2. If you get stuck, the finished file is in [`.workshop/solutions/03-mcp/`](.workshop/solutions/03-mcp/).
+That's the whole code change. `client.get_mcp_tool(...)` reads the label and URL from the environment (the same values you just added to `.env`) and hands the agent a remote tool. Everything else in `main.py` — the `FoundryChatClient` setup, the three function tools, `default_options={"store": False}`, and `ResponsesHostServer(agent).run()` — is unchanged from Step 2. If you get stuck, the finished file is in [`.workshop/solutions/03-mcp/`](../../solutions/03-mcp/).
> **Why `os.environ[...]` and not a hardcoded URL?** Reading the label and URL from the environment keeps them out of source control and lets you point at a different MCP server (or the authenticated variant in Troubleshooting) by editing `.env` — no code change. The hosted runtime gets the same values from the manifest at deploy time.
@@ -338,7 +338,7 @@ Make the prompt explicit: give an origin, a destination (IATA codes work well),
## Solution
-> If you get stuck: [`.workshop/solutions/03-mcp/`](.workshop/solutions/03-mcp/)
+> If you get stuck: [`.workshop/solutions/03-mcp/`](../../solutions/03-mcp/)
## Upstream sample
diff --git a/.workshop/docs/steps/04-toolbox.md b/.workshop/docs/steps/04-toolbox.md
index e553085..bd331f4 100644
--- a/.workshop/docs/steps/04-toolbox.md
+++ b/.workshop/docs/steps/04-toolbox.md
@@ -151,7 +151,7 @@ TOOLBOX_ENDPOINT=https://.services.ai.azure.com/api/projects//
Once the toolbox is deployed, you can also see it in the **Foundry Toolkit** for VS Code: expand your project under **My Resources → Tools** and open the **Toolboxes** tab to view your toolbox along with its **Endpoint URL**.
-
+
### 3. Swap the MCP tool for the toolbox in `travel_assistant/main.py`
@@ -221,7 +221,7 @@ and put the toolbox sentence in its place:
),
```
-That's the whole code change. Everything else — the `FoundryChatClient` setup, the three function tools, `default_options={"store": False}`, and the synchronous `ResponsesHostServer(agent).run()` — is unchanged from Step 3. If you get stuck, the finished file is in [`.workshop/solutions/04-toolbox/`](.workshop/solutions/04-toolbox/).
+That's the whole code change. Everything else — the `FoundryChatClient` setup, the three function tools, `default_options={"store": False}`, and the synchronous `ResponsesHostServer(agent).run()` — is unchanged from Step 3. If you get stuck, the finished file is in [`.workshop/solutions/04-toolbox/`](../../solutions/04-toolbox/).
> **Why one `credential`?** The toolbox and the chat client both authenticate to Foundry. Sharing a single `DefaultAzureCredential()` avoids two sign-in flows and keeps token caching in one place.
@@ -461,7 +461,7 @@ The final `tools` list should contain **four** entries: the three function tools
## Solution
-> If you get stuck: [`.workshop/solutions/04-toolbox/`](.workshop/solutions/04-toolbox/)
+> If you get stuck: [`.workshop/solutions/04-toolbox/`](../../solutions/04-toolbox/)
## Upstream sample
diff --git a/.workshop/docs/steps/05-rag.md b/.workshop/docs/steps/05-rag.md
index f80cd39..65ad84c 100644
--- a/.workshop/docs/steps/05-rag.md
+++ b/.workshop/docs/steps/05-rag.md
@@ -572,7 +572,7 @@ You can also create both from the **Azure portal**. See the links under **Learn
## Solution
-> If you get stuck: [`.workshop/solutions/05-rag/`](.workshop/solutions/05-rag/)
+> If you get stuck: [`.workshop/solutions/05-rag/`](../../solutions/05-rag/)
## Upstream sample
diff --git a/.workshop/docs/steps/06-skills.md b/.workshop/docs/steps/06-skills.md
index d8caa24..8212884 100644
--- a/.workshop/docs/steps/06-skills.md
+++ b/.workshop/docs/steps/06-skills.md
@@ -330,7 +330,7 @@ resources: []
Prefer a UI? Open the **Hosted Agent Playground** from the Foundry Toolkit (**Developer Tools** → **Build** → **Hosted Agent Playground**), pick your deployed agent and version, and ask for a guide — the generated PDF shows up under **Session Details → Files**.
- 
+ 
---
@@ -404,7 +404,7 @@ It prints the uploaded skill's version and `skill_id`, then confirms the project
Prefer a UI? Open the Foundry Toolkit and select your project under **My Resources → Tools → Skills** — the uploaded `response-guardrails` skill appears there with its version and description.
-
+
### B2. Add the download and extend the provider in `main.py`
@@ -727,7 +727,7 @@ That folder is a **runtime cache** in the OS temp dir — `main.py` deletes and
## Solution
-- [`.workshop/solutions/06-skills/`](.workshop/solutions/06-skills/) — the local skill **and** the required Foundry skill, with `foundry_skills/` as a sibling of `travel_assistant/`.
+- [`.workshop/solutions/06-skills/`](../../solutions/06-skills/) — the local skill **and** the required Foundry skill, with `foundry_skills/` as a sibling of `travel_assistant/`.
## Upstream sample
diff --git a/.workshop/docs/steps/07-multi-agent.md b/.workshop/docs/steps/07-multi-agent.md
index c958b36..cb9906d 100644
--- a/.workshop/docs/steps/07-multi-agent.md
+++ b/.workshop/docs/steps/07-multi-agent.md
@@ -223,7 +223,7 @@ Keep import names aligned with the files you created in Steps 5–6. `coordinato
## Solution
-> If you get stuck: [`.workshop/solutions/07-multi-agent/`](.workshop/solutions/07-multi-agent/)
+> If you get stuck: [`.workshop/solutions/07-multi-agent/`](../../solutions/07-multi-agent/)
## Upstream sample
diff --git a/.workshop/docs/steps/08-workflow.md b/.workshop/docs/steps/08-workflow.md
index d10cbc3..1232156 100644
--- a/.workshop/docs/steps/08-workflow.md
+++ b/.workshop/docs/steps/08-workflow.md
@@ -388,7 +388,7 @@ Hosted workflow-agents surface `request_info` as a **function call**, not a prom
## Solution
-> If you get stuck: [`.workshop/solutions/08-workflow/`](.workshop/solutions/08-workflow/)
+> If you get stuck: [`.workshop/solutions/08-workflow/`](../../solutions/08-workflow/)
## Upstream sample
diff --git a/.workshop/docs/steps/09-memory.md b/.workshop/docs/steps/09-memory.md
index cb1ca8f..7a6d5a4 100644
--- a/.workshop/docs/steps/09-memory.md
+++ b/.workshop/docs/steps/09-memory.md
@@ -305,7 +305,7 @@ If cleanup lists it as skipped, the name has no prefix match — delete it manua
## Solution
-> If you get stuck: [`.workshop/solutions/09-memory/`](.workshop/solutions/09-memory/)
+> If you get stuck: [`.workshop/solutions/09-memory/`](../../solutions/09-memory/)
## Upstream sample
diff --git a/.workshop/scripts/lint_steps.py b/.workshop/scripts/lint_steps.py
index 33ddc2a..e8144be 100644
--- a/.workshop/scripts/lint_steps.py
+++ b/.workshop/scripts/lint_steps.py
@@ -4,6 +4,7 @@
import argparse
import json
+import posixpath
import re
import sys
from dataclasses import asdict, dataclass
@@ -12,7 +13,16 @@
sys.path.insert(0, str(Path(__file__).parent))
-from render_readme import FINAL_STEP, STEP_TITLES, parse_step_marker, render
+from render_readme import (
+ FINAL_STEP,
+ PARTIALS_BASE,
+ STEP_TITLES,
+ STEPS_BASE,
+ _LINK_RE,
+ parse_step_marker,
+ render,
+ resolve_relative_target,
+)
SCRIPT_DIR = Path(__file__).parent
# This script lives at .workshop/scripts/, so the repo root is two levels up and
@@ -151,6 +161,63 @@ def _scan_stray_placeholders() -> list[str]:
return issues
+def _source_link_failures() -> list[str]:
+ """Flag relative links/images in source docs that break when viewed directly.
+
+ Source step docs and partials must author relative links/images *relative to
+ their own folder* so they resolve when the file is browsed directly on GitHub
+ (the renderer rebases them to repo-root-relative for ``README.md``). A link is
+ a failure when, interpreted that way, its target does not exist — either
+ because it was authored root-relative (the old convention) or because the
+ target is simply missing (e.g. a stale image path).
+ """
+
+ failures: list[str] = []
+ scan = [
+ (WORKSHOP_DIR / "docs" / "steps", STEPS_BASE),
+ (WORKSHOP_DIR / "docs" / "partials", PARTIALS_BASE),
+ ]
+
+ for directory, base in scan:
+ if not directory.exists():
+ continue
+
+ for path in sorted(directory.glob("*.md")):
+ lines = path.read_text(encoding="utf-8").splitlines()
+ for line_number, line in enumerate(lines, start=1):
+ for match in _LINK_RE.finditer(line):
+ url = match.group("url")
+ target = resolve_relative_target(url, base)
+ if target is None:
+ continue
+
+ location = f"{_relative(path)}:{line_number}"
+ if target == ".." or target.startswith("../"):
+ failures.append(
+ f"{location}: link '{url}' escapes the repo root "
+ f"(resolves to '{target}') — links must stay inside "
+ "the repository"
+ )
+ continue
+ if (REPO_ROOT / target).exists():
+ continue
+
+ root_target = posixpath.normpath(url.partition("#")[0])
+ if (REPO_ROOT / root_target).exists():
+ failures.append(
+ f"{location}: root-relative link '{url}' — author it "
+ "source-relative (source docs resolve links from the "
+ "doc's own folder when viewed on GitHub)"
+ )
+ else:
+ failures.append(
+ f"{location}: broken link '{url}' — target not found "
+ f"(resolved to '{target}')"
+ )
+
+ return failures
+
+
def _add_result(
results: list[CheckResult],
check_id: str,
@@ -333,6 +400,15 @@ def run_checks() -> tuple[list[CheckResult], list[str]]:
"no stray owner/repo placeholders outside GitHub URLs",
)
+ link_failures = _source_link_failures()
+ _add_result(
+ results,
+ "K",
+ "Source-doc relative links resolve (source-relative, no broken targets)",
+ link_failures,
+ "source-doc relative links/images resolve from the doc's own folder",
+ )
+
return results, warnings
diff --git a/.workshop/scripts/render_readme.py b/.workshop/scripts/render_readme.py
index caf291c..0128b07 100644
--- a/.workshop/scripts/render_readme.py
+++ b/.workshop/scripts/render_readme.py
@@ -9,6 +9,7 @@
import argparse
import glob
+import posixpath
import re
import subprocess
import sys
@@ -18,6 +19,11 @@
REPO_ROOT = Path(__file__).resolve().parents[2]
PARTIALS_DIR = REPO_ROOT / ".workshop" / "docs" / "partials"
STEPS_DIR = REPO_ROOT / ".workshop" / "docs" / "steps"
+# The folders that source docs live in, as repo-root-relative POSIX paths. These
+# are the bases the link rebaser rewrites *from*. They are captured at import so
+# tests that monkeypatch ``STEPS_DIR`` to a scratch folder don't disturb them.
+STEPS_BASE = STEPS_DIR.relative_to(REPO_ROOT).as_posix()
+PARTIALS_BASE = PARTIALS_DIR.relative_to(REPO_ROOT).as_posix()
TERMINAL_STEP = 9
FINAL_STEP = 99
@@ -40,6 +46,81 @@
_PLACEHOLDER_OWNER = "{{OWNER}}"
_PLACEHOLDER_REPO = "{{REPO}}"
+# Matches Markdown inline links and images: ``[text](url)`` and ````.
+# The URL is captured up to whitespace or the closing paren; an optional
+# ``"title"`` after the URL is captured separately in ``tail`` and preserved.
+# Reference-style links, autolinks (````), and code spans are
+# intentionally not matched — none carry rebasable relative paths in our docs.
+_LINK_RE = re.compile(
+ r"(?P