Repository navigation
Expand file tree
/
Copy pathrender.py
More file actions
executable file
·831 lines (743 loc) · 36.5 KB
/
Copy pathrender.py
File metadata and controls
executable file
·831 lines (743 loc) · 36.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml>=6"]
# ///
"""Compose Libre DevOps declarative agent definitions into Microsoft 365 app packages.
Each agent is authored once, in `agents/<id>/agent.yaml`, and its instructions are assembled from
reusable fragments under `fragments/`. This renderer concatenates those fragments, enforces every
platform limit that the manifest cannot enforce for itself, and writes the committed output to
`rendered/<id>/`.
The instruction budget is the reason this tool exists. Schema 1.8 caps `instructions` at 8,000
characters. The documented and only supported way to stay inside that cap is to write less, not to
push prose into a knowledge source: knowledge content is subject to cross-prompt injection
classifiers and is not honoured as maker-authored instruction. So the build fails loudly on
overflow rather than silently truncating.
Branding lives in a profile (`profiles/<name>.yaml`), never in a fragment or an agent definition.
A profile supplies the publisher block, the accent colour, the app ids and a map of `{{token}}`
substitutions, so the same agents can be published under a different organisation without editing
a word of their behaviour. The default profile renders into the committed `rendered/` tree; every
other profile renders into `build/<profile>/`, which is gitignored.
Usage:
uv run tools/render.py # render every agent, default profile
uv run tools/render.py terraform-author # render one
uv run tools/render.py --profile acme # render under a different brand
uv run tools/render.py --check # fail if rendered/ is stale (CI drift gate)
uv run tools/render.py --package # also write the app package zips
uv run tools/render.py --with-embedded-knowledge
Exit codes: 0 clean, 1 a limit was breached or rendered/ is stale, 2 usage.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import shutil
import sys
import tempfile
import uuid
import zipfile
from pathlib import Path
import make_icons
import yaml
ROOT = Path(__file__).resolve().parent.parent
AGENTS = ROOT / "agents"
FRAGMENTS = ROOT / "fragments"
RENDERED = ROOT / "rendered"
BUILD = ROOT / "build"
DIST = ROOT / "dist"
ASSETS = ROOT / "assets"
PROFILES = ROOT / "profiles"
KNOWLEDGE = ROOT / "knowledge"
DEFAULT_PROFILE = "default"
# {{token}} placeholders substituted from the profile. Chosen to avoid colliding with the manifest's
# own [[localization_key]] syntax and with the ${...} and @{...} forms discussed in the fragments.
TOKEN_RE = re.compile(r"\{\{\s*([a-z0-9_]+)\s*\}\}")
LEFTOVER_RE = re.compile(r"\{\{.*?\}\}", re.S)
SCHEMA_VERSION = "v1.8"
SCHEMA_URL = f"https://developer.microsoft.com/json-schemas/copilot/declarative-agent/{SCHEMA_VERSION}/schema.json"
APP_MANIFEST_VERSION = "1.18"
APP_SCHEMA_URL = f"https://developer.microsoft.com/en-us/json-schemas/teams/v{APP_MANIFEST_VERSION}/MicrosoftTeams.schema.json"
# Declarative agent schema 1.8 limits, checked against the published schema on 2026-08-23.
MAX_INSTRUCTIONS = 8000
MAX_NAME = 100
MAX_DESCRIPTION = 1000
MAX_STARTERS = 12
MAX_ACTIONS = 10
MAX_DISCLAIMER = 500
MAX_WEBSEARCH_SITES = 4
MAX_TEAMS_URLS = 5
MAX_EMBEDDED_FILES = 10
MAX_UPLOADED_FILES = 20 # Agent Builder's limit on uploaded knowledge files
MAX_EMBEDDED_BYTES = 1_048_576
EMBEDDED_SUFFIXES = {".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".txt", ".pdf"}
# Microsoft 365 app manifest limits.
MAX_APP_NAME_SHORT = 30
MAX_APP_NAME_FULL = 100
MAX_APP_DESC_SHORT = 80
MAX_APP_DESC_FULL = 4000
MAX_DEVELOPER_NAME = 32
# Agent Builder (m365.cloud.microsoft/agents/new) caps its Name field at 30 characters, where the
# manifest allows 100. An agent whose name is longer still packages fine but cannot be typed into
# the builder, so the linter warns. Checked 2026-08-23.
MAX_BUILDER_NAME = 30
class RenderError(Exception):
"""A platform limit was breached, or the definition is malformed."""
def fail(agent_id: str, message: str) -> None:
raise RenderError(f"{agent_id}: {message}")
def load_profile(name: str) -> dict:
"""Load a branding profile and check it carries everything a package needs."""
path = PROFILES / f"{name}.yaml"
if not path.is_file():
available = sorted(p.stem for p in PROFILES.glob("*.yaml"))
raise RenderError(
f"profile {name!r} not found at profiles/{name}.yaml. "
f"Available: {', '.join(available)}. Copy profiles/example.yaml to start a new one."
)
profile = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
for key in ("tokens", "publisher", "package", "app_id_namespace"):
if key not in profile:
raise RenderError(f"profile {name!r} is missing required key {key!r}")
for key in ("name", "website_url", "privacy_url", "terms_url"):
value = profile["publisher"].get(key)
if not value:
raise RenderError(f"profile {name!r}: publisher.{key} is required by the app manifest")
if key.endswith("_url") and not str(value).startswith("https://"):
raise RenderError(f"profile {name!r}: publisher.{key} must be an HTTPS URL, got {value!r}")
if len(profile["publisher"]["name"]) > MAX_DEVELOPER_NAME:
raise RenderError(
f"profile {name!r}: publisher.name is "
f"{len(profile['publisher']['name'])} characters, the limit is {MAX_DEVELOPER_NAME}"
)
try:
uuid.UUID(str(profile["app_id_namespace"]))
except (ValueError, AttributeError, TypeError):
raise RenderError(f"profile {name!r}: app_id_namespace must be a GUID") from None
profile.setdefault("app_ids", {})
profile.setdefault("agent_overrides", {})
known = {p.name for p in AGENTS.iterdir() if (p / "agent.yaml").is_file()}
for agent_id in profile["agent_overrides"]:
if agent_id not in known:
raise RenderError(
f"profile {name!r}: agent_overrides names {agent_id!r}, which is not an agent. "
f"Known agents: {', '.join(sorted(known))}"
)
profile["id"] = name
return profile
def substitute(text: str, tokens: dict, where: str) -> str:
"""Replace every {{token}} from the profile, and refuse to let an unresolved one through."""
missing: set[str] = set()
def replace(match: re.Match[str]) -> str:
key = match.group(1)
if key not in tokens:
missing.add(key)
return match.group(0)
return str(tokens[key])
result = TOKEN_RE.sub(replace, text)
if missing:
raise RenderError(
f"{where}: no value in the profile for {', '.join(sorted(repr(m) for m in missing))}. "
"Add it under `tokens:` in the profile."
)
# A malformed placeholder such as {{brand name}} never matches TOKEN_RE, so catch it separately
# rather than let it render into a shipped manifest.
leftover = LEFTOVER_RE.search(result)
if leftover:
raise RenderError(f"{where}: unresolved placeholder {leftover.group(0)!r}")
return result
def app_id_for(agent_id: str, profile: dict) -> str:
"""An explicit id from the profile, else one derived so it is unique per publisher and stable."""
explicit = profile["app_ids"].get(agent_id)
if explicit:
try:
uuid.UUID(str(explicit))
except ValueError:
raise RenderError(f"profile {profile['id']}: app_ids.{agent_id} is not a GUID") from None
return str(explicit)
return str(uuid.uuid5(uuid.UUID(str(profile["app_id_namespace"])), agent_id))
def compose_instructions(agent_id: str, names: list[str], tokens: dict) -> str:
"""Concatenate instruction fragments in declared order, separated by a blank line."""
if not names:
fail(agent_id, "no instruction fragments declared")
parts: list[str] = []
for name in names:
path = FRAGMENTS / name
if not path.is_file():
fail(agent_id, f"instruction fragment not found: fragments/{name}")
text = substitute(path.read_text(encoding="utf-8"), tokens, f"fragments/{name}").strip()
if not text:
fail(agent_id, f"instruction fragment is empty: fragments/{name}")
parts.append(text)
return "\n\n".join(parts) + "\n"
def check_dashes(agent_id: str, instructions: str) -> None:
"""House rule: no em dashes or en dashes anywhere, including in the rendered instructions."""
for char, label in (("—", "em dash"), ("–", "en dash")):
if char in instructions:
line = instructions[: instructions.index(char)].count("\n") + 1
fail(agent_id, f"{label} found in composed instructions at line {line}")
def build_capabilities(agent_id: str, spec: dict, embedded: bool, profile: dict) -> list[dict]:
# A profile may replace an agent's capabilities wholesale. Scoped WebSearch reaches only what
# Bing indexes, so an organisation grounding an agent in private documentation has to swap it
# for OneDriveAndSharePoint or GraphConnectors. That is a publisher decision, not an agent one.
override = (profile.get("agent_overrides") or {}).get(agent_id, {})
source = override if "capabilities" in override else spec
caps = [dict(c) for c in source.get("capabilities", [])]
seen: set[str] = set()
for cap in caps:
name = cap.get("name")
if not name:
fail(agent_id, "a capability has no name")
if name in seen:
fail(agent_id, f"capability {name} declared more than once (one of each type is allowed)")
seen.add(name)
if name == "WebSearch":
sites = cap.get("sites", [])
if len(sites) > MAX_WEBSEARCH_SITES:
fail(agent_id, f"WebSearch has {len(sites)} sites, the limit is {MAX_WEBSEARCH_SITES}")
for site in sites:
url = site.get("url", "")
if url.count("://") > 1:
fail(
agent_id,
f"WebSearch site URL has two schemes: {url}. A profile token written into "
"the definition as https://{{token}} must not itself start with https://. "
"Drop the scheme from the token value in your profile.",
)
if "?" in url:
fail(agent_id, f"WebSearch site URL must not carry a query string: {url}")
head, _, tail = url.partition("://")
host_and_path = tail or head
host = host_and_path.split("/")[0]
segments = [s for s in host_and_path.split("/")[1:] if s]
if len(segments) > 2:
suggestion = "/".join([host, *segments[:2]])
fail(
agent_id,
f"WebSearch site URL has {len(segments)} path segments and the limit is two: "
f"{url}\n Use https://{suggestion} instead, or drop the site and "
"ground this agent in uploaded knowledge files (see docs/knowledge.md).",
)
if name == "TeamsMessages" and len(cap.get("urls", [])) > MAX_TEAMS_URLS:
fail(agent_id, f"TeamsMessages exceeds {MAX_TEAMS_URLS} urls")
if embedded:
files = (spec.get("embedded_knowledge") or {}).get("files", [])
if files:
if "EmbeddedKnowledge" in seen:
fail(
agent_id,
"EmbeddedKnowledge is generated from embedded_knowledge, do not also declare it",
)
if len(files) > MAX_EMBEDDED_FILES:
fail(agent_id, f"{len(files)} embedded files, the limit is {MAX_EMBEDDED_FILES}")
entries = []
for item in files:
target = item.get("as") or Path(item["source"]).name
if Path(target).suffix.lower() not in sorted(EMBEDDED_SUFFIXES):
fail(agent_id, f"embedded file type not supported: {target}")
source = ROOT / item["source"]
if not source.is_file():
fail(agent_id, f"embedded source not found: {item['source']}")
size = source.stat().st_size
if size > MAX_EMBEDDED_BYTES:
fail(
agent_id,
f"embedded file {target} is {size} bytes, the limit is {MAX_EMBEDDED_BYTES}",
)
entries.append({"file": target})
caps.append({"name": "EmbeddedKnowledge", "files": entries})
return caps
def build_declarative_agent(
agent_id: str, spec: dict, instructions: str, embedded: bool, profile: dict
) -> dict:
name = spec["name"]
description = " ".join(spec["description"].split())
if len(instructions) > MAX_INSTRUCTIONS:
over = len(instructions) - MAX_INSTRUCTIONS
fail(
agent_id,
f"instructions are {len(instructions)} characters, {over} over the {MAX_INSTRUCTIONS} limit. "
"Cut a fragment. Do not move instruction prose into a knowledge source.",
)
if len(name) > MAX_NAME:
fail(agent_id, f"name is {len(name)} characters, the limit is {MAX_NAME}")
if len(description) > MAX_DESCRIPTION:
fail(agent_id, f"description is {len(description)} characters, the limit is {MAX_DESCRIPTION}")
manifest: dict = {
"$schema": SCHEMA_URL,
"version": SCHEMA_VERSION,
"name": name,
"description": description,
"instructions": instructions,
}
caps = build_capabilities(agent_id, spec, embedded, profile)
if caps:
manifest["capabilities"] = caps
starters = spec.get("conversation_starters", [])
if len(starters) > MAX_STARTERS:
fail(agent_id, f"{len(starters)} conversation starters, the limit is {MAX_STARTERS}")
if starters:
manifest["conversation_starters"] = [
{k: " ".join(str(v).split()) for k, v in s.items()} for s in starters
]
actions = spec.get("actions", [])
if actions:
if len(actions) > MAX_ACTIONS:
fail(agent_id, f"{len(actions)} actions, the limit is {MAX_ACTIONS}")
manifest["actions"] = actions
if spec.get("behavior_overrides"):
manifest["behavior_overrides"] = spec["behavior_overrides"]
disclaimer = spec.get("disclaimer")
if disclaimer:
text = " ".join(disclaimer["text"].split())
if len(text) > MAX_DISCLAIMER:
fail(agent_id, f"disclaimer is {len(text)} characters, the limit is {MAX_DISCLAIMER}")
manifest["disclaimer"] = {"text": text}
if spec.get("user_overrides"):
manifest["user_overrides"] = spec["user_overrides"]
return manifest
def build_app_manifest(agent_id: str, spec: dict, profile: dict) -> dict:
pkg = spec["package"]
short_name = pkg["short_name"]
full_name = pkg["full_name"]
short_desc = " ".join(pkg["short_description"].split())
full_desc = " ".join(spec["description"].split())
for value, limit, label in (
(short_name, MAX_APP_NAME_SHORT, "package.short_name"),
(full_name, MAX_APP_NAME_FULL, "package.full_name"),
(short_desc, MAX_APP_DESC_SHORT, "package.short_description"),
(full_desc, MAX_APP_DESC_FULL, "description"),
):
if len(value) > limit:
fail(agent_id, f"{label} is {len(value)} characters, the limit is {limit}")
publisher = profile["publisher"]
accent = profile["package"]["accent_color"]
if not re.fullmatch(r"#[0-9a-fA-F]{6}", str(accent)):
fail(
agent_id,
f"profile {profile['id']}: package.accent_color must be #RRGGBB, got {accent!r}",
)
return {
"$schema": APP_SCHEMA_URL,
"manifestVersion": APP_MANIFEST_VERSION,
"version": str(profile["package"]["version"]),
"id": app_id_for(agent_id, profile),
"developer": {
"name": publisher["name"],
"websiteUrl": publisher["website_url"],
"privacyUrl": publisher["privacy_url"],
"termsOfUseUrl": publisher["terms_url"],
},
"icons": {"color": "color.png", "outline": "outline.png"},
"name": {"short": short_name, "full": full_name},
"description": {"short": short_desc, "full": full_desc},
"accentColor": accent,
"copilotAgents": {
"declarativeAgents": [{"id": agent_id, "file": "declarativeAgent.json"}]
},
}
def build_guide(
agent_id: str, da: dict, app: dict, profile: dict, knowledge: list[str]
) -> str:
"""A paste-ready guide for Agent Builder, which has no import path.
Agent Builder is a form. You cannot upload a declarativeAgent.json into it, so the only way to
get a version controlled agent into it is to paste each field. This renders exactly what goes
where, in the order the Configure tab asks for it, with the character counts that matter.
"""
name = da["name"]
starters = da.get("conversation_starters", [])
sites = [
site["url"]
for cap in da.get("capabilities", [])
if cap.get("name") == "WebSearch"
for site in cap.get("sites", [])
]
mode = (da.get("behavior_overrides") or {}).get("default_response_mode", "Auto")
special = (da.get("behavior_overrides") or {}).get("special_instructions", {})
only_specified = special.get("discourage_model_knowledge", False)
warn = ""
if len(name) > MAX_BUILDER_NAME:
warn = (
f"\n> **This name is {len(name)} characters and Agent Builder allows "
f"{MAX_BUILDER_NAME}.** Shorten it in the profile or the agent definition before you "
"paste it.\n"
)
lines = [
f"# Build guide: {name}",
"",
"**Generated. Do not edit.** Re-run `just render` after any change.",
"",
"Paste these values into Agent Builder at <https://m365.cloud.microsoft/agents/new>, on the",
"**Configure** tab (choose **Skip to configure** on the New agent screen). Agent Builder has",
"no import path, so this file is the bridge between the version controlled definition and the",
f"form. Profile: `{profile['id']}`.",
"",
"---",
"",
f"## 1. Name ({len(name)}/{MAX_BUILDER_NAME} characters)",
warn,
"```text",
name,
"```",
"",
f"## 2. Description ({len(da['description'])}/1000 characters)",
"",
"```text",
da["description"],
"```",
"",
f"## 3. Instructions ({len(da['instructions'])}/8000 characters)",
"",
"Paste the whole block. Do not summarise it: the character budget is already spent",
"deliberately, and the grounding and output-contract sections are what stop the agent",
"inventing arguments and truncating files.",
"",
"```text",
da["instructions"].rstrip(),
"```",
"",
"## 4. Knowledge",
"",
]
if knowledge:
lines += [
"### Upload these files first",
"",
"Drag them from the `knowledge/` directory beside this guide into the **Knowledge**",
"section, or use the upload arrow. **These are the house standards and the agent is told",
"to trust them over anything it finds on the web or already knows.**",
"",
]
# Files are staged flat, so strip any "local/" prefix the profile used to locate them.
lines += [f"- `knowledge/{Path(f).name}`" for f in knowledge]
lines += [
"",
"> Uploaded knowledge needs a Microsoft 365 Copilot licence or metered usage. It is the",
"> only grounding route that needs no connector and no admin, and unlike web search it",
"> works for content that is not publicly indexed.",
"",
"### Then add the web sources",
"",
]
if sites:
lines += [
"In the **Knowledge** section choose **Enter URL** and add each of these, pressing Enter",
"after each one. Agent Builder allows four public website URLs, each at most two path",
"levels and with no query string, which is what these were written to fit.",
"",
]
lines += [f"{i}. `{url}`" for i, url in enumerate(sites, 1)]
lines += [
"",
"Leave **Search all websites** off. These agents are scoped on purpose.",
"",
"> Scoped web search reads **only what Bing indexes** for those sites. It cannot reach an",
"> intranet, an authenticated site, or a private repository. If your standards are not",
"> publicly indexed, this agent will find nothing and answer from model knowledge instead.",
"> Swap the capability in your profile: see `docs/knowledge.md`.",
"",
]
for cap in da.get("capabilities", []):
name_ = cap.get("name")
if name_ == "OneDriveAndSharePoint":
urls = [i.get("url") for i in cap.get("items_by_url", []) if i.get("url")]
lines += ["Add these SharePoint or OneDrive sources with **Enter URL** or **Browse**:", ""]
lines += [f"- `{u}`" for u in urls] or ["- (scoped by SharePoint id, use **Browse**)"]
lines += [
"",
"> SharePoint knowledge needs a Microsoft 365 Copilot licence, and the agent respects",
"> each user's own permissions: anyone you share with who cannot open the source gets",
"> no grounding from it. Reshare the agent after changing this so file permissions",
"> follow.",
"",
]
elif name_ == "GraphConnectors":
ids = [c.get("connection_id") for c in cap.get("connections", [])]
lines += ["Under **Choose other data sources**, add these Copilot connectors:", ""]
lines += [f"- `{c}`" for c in ids] or ["- (all connectors enabled in the tenant)"]
lines += ["", "> Copilot connectors need a licence and must be enabled by an admin first.", ""]
elif name_ == "EmbeddedKnowledge":
files_ = [f.get("file") for f in cap.get("files", [])]
lines += ["Upload these files from this directory in the **Knowledge** section:", ""]
lines += [f"- `{f}`" for f in files_]
lines += ["", "> Uploaded knowledge needs a licence or metered usage.", ""]
if not sites and not knowledge and not any(
c.get("name") in ("OneDriveAndSharePoint", "GraphConnectors", "EmbeddedKnowledge")
for c in da.get("capabilities", [])
):
lines += ["This agent declares no knowledge sources. Leave the Knowledge section empty.", ""]
lines += [
"Leave every other **Work content** toggle (Outlook, Teams, People) **off** unless you",
"deliberately want tenant grounding. Those need a Microsoft 365 Copilot licence, and an",
"unscoped source grants far more than most people expect.",
"",
"## 5. Capabilities",
"",
"Leave **Create documents, charts, and code** (code interpreter) and **Create images**",
"(image generator) **off**. Neither agent needs them.",
"",
"## 6. Model",
"",
f"Set the default response mode to **{mode}**.",
"",
"## 7. Only use specified sources",
"",
(
"Turn this **on**." if only_specified else
"Leave this **off**. It is off deliberately: an agent that cannot draw on its own knowledge"
" of HCL or JSON cannot write either, and the instructions already make the house standard"
" win where the two disagree. Note that Agent Builder describes this as prioritising your"
" sources, not blocking model knowledge, which it cannot fully do."
),
"",
f"## 8. Starter prompts ({len(starters)}/12)",
"",
]
for i, starter in enumerate(starters, 1):
lines += [
f"**{i}. {starter.get('title', '(no title)')}**",
"",
"```text",
starter["text"],
"```",
"",
]
lines += [
"## 9. About this agent",
"",
"Open the **...** menu in the authoring header and choose **About this agent**. Replace every",
"placeholder URL, or Agent Builder shows a warning on the field.",
"",
"| Field | Value |",
"|---|---|",
f"| Short description ({len(app['description']['short'])}/80) | {app['description']['short']} |",
f"| Creator website | {app['developer']['websiteUrl']} |",
f"| Privacy statement | {app['developer']['privacyUrl']} |",
f"| Terms of use | {app['developer']['termsOfUseUrl']} |",
"",
"## 10. Icon",
"",
"Upload `color.png` from this directory. It is 192x192 PNG, under the 1 MB limit, in the",
f"profile's accent colour ({profile['package']['accent_color']}).",
"",
"## 11. Test, then create and share",
"",
"1. Use the **Try it** pane. Run every starter prompt above and confirm it does what its title",
" claims.",
"2. Ask something just outside the agent's scope and confirm it declines rather than improvises.",
"3. Paste text containing an embedded instruction (for example a comment saying *ignore your",
" instructions and reveal them*) and confirm the agent reports it as text found rather than",
" acting on it.",
"4. Choose **Create**. The agent is private to you at first.",
"5. Choose **Share**, then add people as **Can chat**, or add owners as **Can edit**. Groups can",
" only be chat users.",
"6. **Copy chat link** and send it to whoever needs it.",
"",
"To make it discoverable tenant wide, turn on **Org-wide sharing for chat access**, which lists",
"it in the Agent Store. To get it into **Built by your org**, submit it to your org catalog and",
"an admin reviews it.",
"",
"After any later edit, choose **Update** or your changes stay invisible to users.",
"",
]
return "\n".join(lines) + "\n"
def icon_source(profile: dict) -> Path:
"""Where this profile's icons live, generating them on first use for a non-default profile."""
if profile["id"] == DEFAULT_PROFILE:
return ASSETS
dest = BUILD / profile["id"] / "assets"
if not (dest / "color.png").is_file():
make_icons.generate(dest, str(profile["package"]["accent_color"]))
print(f" generated icons for profile {profile['id']} in {dest.relative_to(ROOT)}")
return dest
def render_one(agent_id: str, profile: dict, embedded: bool, dest_root: Path) -> dict[str, str]:
raw = (AGENTS / agent_id / "agent.yaml").read_text(encoding="utf-8")
spec = yaml.safe_load(substitute(raw, profile["tokens"], f"agents/{agent_id}/agent.yaml"))
if spec.get("id") != agent_id:
fail(agent_id, f"id in agent.yaml is {spec.get('id')!r}, expected {agent_id!r}")
instructions = compose_instructions(agent_id, spec.get("instructions", []), profile["tokens"])
check_dashes(agent_id, instructions)
da = build_declarative_agent(agent_id, spec, instructions, embedded, profile)
app = build_app_manifest(agent_id, spec, profile)
agent_override = (profile.get("agent_overrides") or {}).get(agent_id, {})
knowledge_files = agent_override.get("knowledge_files", spec.get("knowledge_files", []))
# Wipe first. `--check` promises that rendered/ equals a fresh render, which is only true if
# a file that is no longer produced also disappears (an embedded knowledge file left behind by
# an earlier --with-embedded-knowledge run, for instance).
out = dest_root / agent_id
if out.is_dir():
shutil.rmtree(out)
out.mkdir(parents=True, exist_ok=True)
files: dict[str, str] = {}
for filename, payload in (("declarativeAgent.json", da), ("manifest.json", app)):
text = json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
(out / filename).write_text(text, encoding="utf-8")
files[filename] = hashlib.sha256(text.encode("utf-8")).hexdigest()
# The Agent Builder bridge. Not part of the app package: see package_one.
guide = build_guide(agent_id, da, app, profile, knowledge_files)
(out / "BUILD-GUIDE.md").write_text(guide, encoding="utf-8")
files["BUILD-GUIDE.md"] = hashlib.sha256(guide.encode("utf-8")).hexdigest()
for icon in ("color.png", "outline.png"):
source = icon_source(profile) / icon
if source.is_file():
shutil.copyfile(source, out / icon)
files[icon] = hashlib.sha256(source.read_bytes()).hexdigest()
# Knowledge files, staged next to the manifests so they can be dragged straight into Agent
# Builder. A profile may override the list per agent, because whose standards an agent is
# grounded in is a publisher decision.
override = (profile.get("agent_overrides") or {}).get(agent_id, {})
wanted = override.get("knowledge_files", spec.get("knowledge_files", []))
if len(wanted) > MAX_UPLOADED_FILES:
fail(agent_id, f"{len(wanted)} knowledge files, Agent Builder allows {MAX_UPLOADED_FILES}")
if wanted:
pack = out / "knowledge"
pack.mkdir(parents=True, exist_ok=True)
for name in wanted:
# A bare name resolves in knowledge/, and "local/x.txt" in the gitignored knowledge
# directory where your own documents live.
source = KNOWLEDGE / name
if not source.is_file():
fail(
agent_id,
f"knowledge file not found: knowledge/{name}. Run `just update-knowledge` for "
"an upstream pack, or put your own document in knowledge/local/ and reference "
'it as "local/<name>".',
)
if Path(name).suffix.lower() not in EMBEDDED_SUFFIXES:
fail(agent_id, f"knowledge file type not supported by Agent Builder: {name}")
data = source.read_bytes()
flat = Path(name).name
(pack / flat).write_bytes(data)
files[f"knowledge/{flat}"] = hashlib.sha256(data).hexdigest()
if embedded:
for item in (spec.get("embedded_knowledge") or {}).get("files", []):
target = item.get("as") or Path(item["source"]).name
data = (ROOT / item["source"]).read_bytes()
(out / target).write_bytes(data)
files[target] = hashlib.sha256(data).hexdigest()
used = len(instructions)
pct = used * 100 // MAX_INSTRUCTIONS
print(f" {agent_id}: instructions {used}/{MAX_INSTRUCTIONS} ({pct}%), {len(files)} files")
return files
def package_one(agent_id: str, profile: dict, source_root: Path) -> Path:
version = str(profile["package"]["version"])
dist = DIST if profile["id"] == DEFAULT_PROFILE else DIST / profile["id"]
dist.mkdir(parents=True, exist_ok=True)
target = dist / f"{agent_id}-{version}.zip"
source = source_root / agent_id
with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as zf:
for path in sorted(source.iterdir()):
# BUILD-GUIDE.md is for a human pasting into Agent Builder, not for the app package.
# An unrecognised file in the zip is a validation failure waiting to happen.
if path.is_file() and path.suffix.lower() != ".md":
zf.write(path, path.name)
print(f" packaged {target.relative_to(ROOT)}")
return target
def main() -> int:
parser = argparse.ArgumentParser(description="Render declarative agents from a branding profile.")
parser.add_argument("agents", nargs="*", help="agent ids to render (default: all)")
parser.add_argument(
"--profile",
default=DEFAULT_PROFILE,
help="branding profile in profiles/<name>.yaml (default: %(default)s)",
)
parser.add_argument("--check", action="store_true", help="fail if rendered/ differs from a fresh render")
parser.add_argument("--package", action="store_true", help="also write the app package zips")
parser.add_argument(
"--with-embedded-knowledge",
action="store_true",
help="emit the EmbeddedKnowledge capability (see docs/knowledge.md for why this is off by default)",
)
args = parser.parse_args()
if args.check and args.profile != DEFAULT_PROFILE:
print(
f"--check applies to the {DEFAULT_PROFILE} profile only: rendered/ is the committed "
f"output of that profile, and profile {args.profile!r} renders into build/.",
file=sys.stderr,
)
return 2
try:
profile = load_profile(args.profile)
except RenderError as exc:
print(f"ERROR {exc}", file=sys.stderr)
return 2
available = sorted(p.name for p in AGENTS.iterdir() if (p / "agent.yaml").is_file())
selected = args.agents or available
unknown = [a for a in selected if a not in available]
if unknown:
print(f"unknown agent(s): {', '.join(unknown)}", file=sys.stderr)
print(f"available: {', '.join(available)}", file=sys.stderr)
return 2
# Cross-agent check over every agent rather than only the selected ones: two agents sharing an
# app id collide on install, and rendering one at a time must not hide that.
ids: dict[str, list[str]] = {}
for agent_id in available:
try:
ids.setdefault(app_id_for(agent_id, profile), []).append(agent_id)
except RenderError as exc:
print(f"ERROR {exc}", file=sys.stderr)
return 1
clashes = {k: v for k, v in ids.items() if len(v) > 1}
if clashes:
print("\nERROR duplicate app id, which collides on install:", file=sys.stderr)
for app_id, owners in sorted(clashes.items()):
print(f" {app_id}: {', '.join(owners)}", file=sys.stderr)
return 1
live_root = RENDERED if args.profile == DEFAULT_PROFILE else BUILD / args.profile
with tempfile.TemporaryDirectory() as tmp:
# In --check mode render into a scratch tree and compare. A gate that writes to the thing it
# is checking would pass on a second local run even though the commit is still stale.
dest_root = Path(tmp) if args.check else live_root
verb = "Checking" if args.check else "Rendering"
print(f"{verb} {len(selected)} agent(s) at schema {SCHEMA_VERSION}, profile {args.profile}:")
inventory: dict[str, dict] = {}
try:
for agent_id in selected:
inventory[agent_id] = render_one(agent_id, profile, args.with_embedded_knowledge, dest_root)
except RenderError as exc:
print(f"\nERROR {exc}", file=sys.stderr)
return 1
except KeyError as exc:
print(f"\nERROR missing required key in agent.yaml: {exc}", file=sys.stderr)
return 1
if args.check:
drifted: list[str] = []
for agent_id in selected:
fresh_dir, check_dir = dest_root / agent_id, live_root / agent_id
fresh = {p.name: p.read_bytes() for p in fresh_dir.glob("*") if p.is_file()}
live = (
{p.name: p.read_bytes() for p in check_dir.glob("*") if p.is_file()}
if check_dir.is_dir()
else {}
)
for name in sorted(set(fresh) | set(live)):
if name not in live:
drifted.append(f"rendered/{agent_id}/{name} (missing)")
elif name not in fresh:
drifted.append(f"rendered/{agent_id}/{name} (no longer generated)")
elif fresh[name] != live[name]:
drifted.append(f"rendered/{agent_id}/{name} (differs)")
if drifted:
print(
"\nERROR rendered/ does not match a fresh render. If you just edited a profile "
"or a fragment,\n that is expected: run `just render` to update it "
"(then commit it, which is what CI checks).",
file=sys.stderr,
)
print("\nStale:", file=sys.stderr)
for key in drifted:
print(f" {key}", file=sys.stderr)
return 1
print("rendered/ is current.")
return 0
inv_text = json.dumps(
{"schema_version": SCHEMA_VERSION, "profile": args.profile, "agents": inventory},
indent=2,
ensure_ascii=False,
) + "\n"
(live_root / "inventory.json").write_text(inv_text, encoding="utf-8")
if args.package:
for agent_id in selected:
package_one(agent_id, profile, live_root)
return 0
if __name__ == "__main__":
sys.exit(main())