Skip to content

Commit a6784c2

Browse files
Antriksh JainCopilot
andcommitted
feat(azure.ai.agents): wire deploy-hook Next: block on host artifact
Wires the nextstep package into deployHostedAgent so successful deploys surface context-aware guidance via the artifact's Metadata["note"]. Implementation: - service_target_agent.go imports azureaiagent/internal/cmd/nextstep. - After deployArtifacts() returns, deployHostedAgent calls nextstep.AssembleState(ctx, p.azdClient), resolves projectRoot via azdClient.Project().Get, then calls augmentDeployNote(...). - New augmentDeployNote(state, artifacts, projectRoot, configDir) walks to the last note-bearing artifact and either REPLACES the aka.ms line (when a local README exists) or APPENDS the Next: block below it (when no README) — implements collision strategy C2. - New helpers: lastNoteArtifact and suggestionsIncludeReadme. - Best-effort: every failure path (nil state, missing project, cache miss, README absent) silently skips the augmentation — deploy success is never blocked by guidance plumbing. - Cached OpenAPI bytes resolved via nextstep.ReadCachedOpenAPISpec with suffix="local" so the resolver can prefer the spec-derived payload over the protocol-generic literal when available. Tests (service_target_agent_test.go): 7 new TestAugmentDeployNote_* cases covering: README absence (append below aka.ms), README presence (replace aka.ms), cached spec yielding payload override, attachment point selection (last endpoint), and three no-op contracts (nil state, no note-bearing artifact, no services). The cached-spec test uses the singular OpenAPI 3.0 'example' key — ExtractInvokeExample does NOT walk the plural 'examples' map (see openapi.go for the resolution order). Pre-existing TestDeployArtifacts_* tests still pass; deployArtifacts is byte-for-byte unchanged. Live-smoke verified against the deployed hello-world-python-invocations sample: aka.ms line correctly replaced, 3 aligned suggestion rows surface. Refs PR Azure#8057 critique items C2 (collision strategy) and design strategy delta D21 (dropped currentIndentation arg from FormatNextForNote — caller indent is unknown to the extension). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fb19392 commit a6784c2

2 files changed

Lines changed: 330 additions & 0 deletions

File tree

cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"strings"
2525
"time"
2626

27+
"azureaiagent/internal/cmd/nextstep"
2728
"azureaiagent/internal/exterrors"
2829
"azureaiagent/internal/pkg/agents"
2930
"azureaiagent/internal/pkg/agents/agent_api"
@@ -970,6 +971,22 @@ func (p *AgentServiceTargetProvider) finalizeDeploy(
970971
protocols,
971972
)
972973

974+
// Best-effort: enrich the last endpoint artifact's note with a
975+
// context-aware "Next:" block. Failures are non-fatal — the static
976+
// aka.ms link emitted by deployArtifacts is preserved when the
977+
// enrichment is skipped or short-circuits.
978+
if state, _ := nextstep.AssembleState(ctx, p.azdClient); state != nil {
979+
projectRoot := ""
980+
if proj, err := p.azdClient.Project().Get(ctx, nil); err == nil && proj.Project != nil {
981+
projectRoot = proj.Project.Path
982+
}
983+
configDir := ""
984+
if projectRoot != "" && p.env != nil && p.env.Name != "" {
985+
configDir = filepath.Join(projectRoot, ".azure", p.env.Name)
986+
}
987+
augmentDeployNote(state, artifacts, projectRoot, configDir)
988+
}
989+
973990
return &azdext.ServiceDeployResult{
974991
Artifacts: artifacts,
975992
}, nil
@@ -1550,6 +1567,110 @@ func (p *AgentServiceTargetProvider) deployArtifacts(
15501567
return artifacts
15511568
}
15521569

1570+
// augmentDeployNote enriches the last endpoint artifact's note with a
1571+
// context-aware "Next:" block resolved from the provided state.
1572+
//
1573+
// Collision rule with the static aka.ms link emitted by deployArtifacts:
1574+
//
1575+
// - When the resolved block contains a "see <relPath>/README.md"
1576+
// suggestion (i.e. a local README exists at the service path), the
1577+
// aka.ms line is replaced entirely — the block already points the
1578+
// user at the more-detailed local doc, so the canned link is
1579+
// redundant.
1580+
// - Otherwise the aka.ms line is preserved and the "Next:" block is
1581+
// appended below, separated by a blank line — aka.ms remains the
1582+
// fallback doc pointer when no local README is present.
1583+
//
1584+
// The function is a no-op when state is nil, no artifact carries a note,
1585+
// or the resolver returns no suggestions; this keeps the deploy path
1586+
// resilient to partial state (e.g. project metadata unavailable) without
1587+
// silencing the original static guidance.
1588+
func augmentDeployNote(state *nextstep.State, artifacts []*azdext.Artifact, projectRoot, configDir string) {
1589+
if state == nil {
1590+
return
1591+
}
1592+
1593+
target := lastNoteArtifact(artifacts)
1594+
if target == nil {
1595+
return
1596+
}
1597+
1598+
cachedPayload := func(serviceName string) string {
1599+
if configDir == "" || serviceName == "" {
1600+
return ""
1601+
}
1602+
spec, err := nextstep.ReadCachedOpenAPISpec(configDir, serviceName, "local")
1603+
if err != nil {
1604+
return ""
1605+
}
1606+
return nextstep.ExtractInvokeExample(spec)
1607+
}
1608+
1609+
readmeExists := func(relativePath string) bool {
1610+
if projectRoot == "" || relativePath == "" {
1611+
return false
1612+
}
1613+
for _, name := range []string{"README.md", "readme.md", "README.MD"} {
1614+
if _, err := os.Stat(filepath.Join(projectRoot, relativePath, name)); err == nil {
1615+
return true
1616+
}
1617+
}
1618+
return false
1619+
}
1620+
1621+
suggestions := nextstep.ResolveAfterDeploy(state, cachedPayload, readmeExists)
1622+
if len(suggestions) == 0 {
1623+
return
1624+
}
1625+
1626+
block := nextstep.FormatNextForNote(suggestions)
1627+
if block == "" {
1628+
return
1629+
}
1630+
1631+
if suggestionsIncludeReadme(suggestions) {
1632+
target.Metadata["note"] = block
1633+
return
1634+
}
1635+
existing := target.Metadata["note"]
1636+
if existing == "" {
1637+
target.Metadata["note"] = block
1638+
return
1639+
}
1640+
target.Metadata["note"] = existing + "\n\n" + block
1641+
}
1642+
1643+
// lastNoteArtifact returns the last artifact in the slice whose
1644+
// Metadata["note"] is non-empty, or nil when none of the artifacts
1645+
// carry a note. deployArtifacts attaches its informational note to the
1646+
// final endpoint artifact only; scanning from the end keeps this in
1647+
// sync should the convention shift to multi-note artifacts in future.
1648+
func lastNoteArtifact(artifacts []*azdext.Artifact) *azdext.Artifact {
1649+
for i := len(artifacts) - 1; i >= 0; i-- {
1650+
a := artifacts[i]
1651+
if a == nil || a.Metadata == nil {
1652+
continue
1653+
}
1654+
if a.Metadata["note"] != "" {
1655+
return a
1656+
}
1657+
}
1658+
return nil
1659+
}
1660+
1661+
// suggestionsIncludeReadme reports whether any suggestion is a local-README
1662+
// pointer (ResolveAfterDeploy emits these as "see <relPath>/README.md").
1663+
// Used by augmentDeployNote to decide whether to replace or append to the
1664+
// existing static aka.ms note.
1665+
func suggestionsIncludeReadme(suggestions []nextstep.Suggestion) bool {
1666+
for _, s := range suggestions {
1667+
if strings.HasPrefix(s.Command, "see ") && strings.HasSuffix(s.Command, "README.md") {
1668+
return true
1669+
}
1670+
}
1671+
return false
1672+
}
1673+
15531674
// protocolEndpointInfo holds a displayable protocol label and its invocation URL.
15541675
type protocolEndpointInfo struct {
15551676
Protocol string

cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import (
1010
"net"
1111
"os"
1212
"path/filepath"
13+
"strings"
1314
"sync/atomic"
1415
"testing"
1516

17+
"azureaiagent/internal/cmd/nextstep"
1618
"azureaiagent/internal/exterrors"
1719
"azureaiagent/internal/pkg/agents/agent_api"
1820
"azureaiagent/internal/pkg/agents/agent_yaml"
@@ -1068,3 +1070,210 @@ func actionableStatusError(t *testing.T, message, suggestion string) error {
10681070
require.NoError(t, err)
10691071
return stWithDetails.Err()
10701072
}
1073+
1074+
func TestAugmentDeployNote_NoReadme_AppendsBelowAkaMsLink(t *testing.T) {
1075+
t.Parallel()
1076+
1077+
tmp := t.TempDir()
1078+
// No README written; readmeExists closure should return false.
1079+
1080+
state := &nextstep.State{
1081+
Services: []nextstep.ServiceState{
1082+
{
1083+
Name: "echo",
1084+
RelativePath: "src/echo",
1085+
Protocol: "invocations",
1086+
IsDeployed: true,
1087+
},
1088+
},
1089+
}
1090+
1091+
artifact := &azdext.Artifact{
1092+
Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT,
1093+
Metadata: map[string]string{
1094+
"label": "Agent endpoint (invocations)",
1095+
"note": "static aka.ms link",
1096+
},
1097+
}
1098+
1099+
augmentDeployNote(state, []*azdext.Artifact{artifact}, tmp, "" /* no configDir → cache lookup is a no-op */)
1100+
1101+
got := artifact.Metadata["note"]
1102+
require.Contains(t, got, "static aka.ms link", "aka.ms link should be preserved when no README is present")
1103+
require.Contains(t, got, "Next:", "Next: block should be appended")
1104+
require.Contains(t, got, "azd ai agent invoke ", "should suggest invoking the deployed agent")
1105+
require.Equal(t, 1, strings.Count(got, "Next:"), "Next: header should appear exactly once")
1106+
}
1107+
1108+
func TestAugmentDeployNote_WithReadme_ReplacesAkaMsLink(t *testing.T) {
1109+
t.Parallel()
1110+
1111+
tmp := t.TempDir()
1112+
servicePath := filepath.Join(tmp, "src", "echo")
1113+
require.NoError(t, os.MkdirAll(servicePath, 0o750))
1114+
require.NoError(t, os.WriteFile(filepath.Join(servicePath, "README.md"), []byte("sample"), 0o600))
1115+
1116+
state := &nextstep.State{
1117+
Services: []nextstep.ServiceState{
1118+
{
1119+
Name: "echo",
1120+
RelativePath: "src/echo",
1121+
Protocol: "invocations",
1122+
IsDeployed: true,
1123+
},
1124+
},
1125+
}
1126+
1127+
artifact := &azdext.Artifact{
1128+
Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT,
1129+
Metadata: map[string]string{
1130+
"label": "Agent endpoint (invocations)",
1131+
"note": "static aka.ms link",
1132+
},
1133+
}
1134+
1135+
augmentDeployNote(state, []*azdext.Artifact{artifact}, tmp, "")
1136+
1137+
got := artifact.Metadata["note"]
1138+
require.NotContains(t, got, "static aka.ms link",
1139+
"aka.ms line must be replaced when a local README provides richer guidance")
1140+
require.Contains(t, got, "Next:", "Next: block should be present")
1141+
require.Contains(t, got, "see src/echo/README.md", "README pointer should be present")
1142+
}
1143+
1144+
func TestAugmentDeployNote_CachedSpecYieldsPayloadOverride(t *testing.T) {
1145+
t.Parallel()
1146+
1147+
tmp := t.TempDir()
1148+
configDir := filepath.Join(tmp, ".azure", "dev")
1149+
require.NoError(t, os.MkdirAll(configDir, 0o750))
1150+
// ReadCachedOpenAPISpec / sanitizeAgentName: the filename uses the agent
1151+
// name verbatim when it contains only safe characters.
1152+
spec := `{
1153+
"paths": {
1154+
"/invocations": {
1155+
"post": {
1156+
"requestBody": {
1157+
"content": {
1158+
"application/json": {
1159+
"example": {"prompt": "from cache"}
1160+
}
1161+
}
1162+
}
1163+
}
1164+
}
1165+
}
1166+
}`
1167+
require.NoError(t, os.WriteFile(filepath.Join(configDir, "openapi-echo-local.json"), []byte(spec), 0o600))
1168+
1169+
state := &nextstep.State{
1170+
Services: []nextstep.ServiceState{
1171+
{
1172+
Name: "echo",
1173+
RelativePath: "src/echo",
1174+
Protocol: "invocations",
1175+
IsDeployed: true,
1176+
},
1177+
},
1178+
}
1179+
1180+
artifact := &azdext.Artifact{
1181+
Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT,
1182+
Metadata: map[string]string{
1183+
"label": "Agent endpoint (invocations)",
1184+
"note": "static aka.ms link",
1185+
},
1186+
}
1187+
1188+
augmentDeployNote(state, []*azdext.Artifact{artifact}, tmp, configDir)
1189+
1190+
got := artifact.Metadata["note"]
1191+
require.Contains(t, got, `"prompt":"from cache"`,
1192+
"cached OpenAPI example should drive the suggested invoke payload")
1193+
}
1194+
1195+
func TestAugmentDeployNote_NoteAttachedToLastEndpoint(t *testing.T) {
1196+
t.Parallel()
1197+
1198+
tmp := t.TempDir()
1199+
1200+
state := &nextstep.State{
1201+
Services: []nextstep.ServiceState{
1202+
{
1203+
Name: "echo",
1204+
RelativePath: "src/echo",
1205+
Protocol: "invocations",
1206+
IsDeployed: true,
1207+
},
1208+
},
1209+
}
1210+
1211+
playground := &azdext.Artifact{
1212+
Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT,
1213+
Metadata: map[string]string{"label": "Agent playground (portal)"},
1214+
}
1215+
first := &azdext.Artifact{
1216+
Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT,
1217+
Metadata: map[string]string{"label": "Agent endpoint (responses)"},
1218+
}
1219+
last := &azdext.Artifact{
1220+
Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT,
1221+
Metadata: map[string]string{
1222+
"label": "Agent endpoint (invocations)",
1223+
"note": "static aka.ms link",
1224+
},
1225+
}
1226+
1227+
augmentDeployNote(state, []*azdext.Artifact{playground, first, last}, tmp, "")
1228+
1229+
require.NotContains(t, playground.Metadata["note"], "Next:", "playground artifact must remain untouched")
1230+
require.NotContains(t, first.Metadata["note"], "Next:", "non-note endpoint must remain untouched")
1231+
require.Contains(t, last.Metadata["note"], "Next:", "augmentation must target the last note-bearing artifact")
1232+
}
1233+
1234+
func TestAugmentDeployNote_NilStateIsNoOp(t *testing.T) {
1235+
t.Parallel()
1236+
1237+
artifact := &azdext.Artifact{
1238+
Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT,
1239+
Metadata: map[string]string{
1240+
"label": "Agent endpoint (invocations)",
1241+
"note": "static aka.ms link",
1242+
},
1243+
}
1244+
augmentDeployNote(nil, []*azdext.Artifact{artifact}, "/tmp", "")
1245+
require.Equal(t, "static aka.ms link", artifact.Metadata["note"], "nil state must leave the static note intact")
1246+
}
1247+
1248+
func TestAugmentDeployNote_NoNoteBearingArtifactIsNoOp(t *testing.T) {
1249+
t.Parallel()
1250+
1251+
state := &nextstep.State{
1252+
Services: []nextstep.ServiceState{
1253+
{Name: "echo", RelativePath: "src/echo", Protocol: "invocations", IsDeployed: true},
1254+
},
1255+
}
1256+
playground := &azdext.Artifact{
1257+
Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT,
1258+
Metadata: map[string]string{"label": "Agent playground (portal)"},
1259+
}
1260+
augmentDeployNote(state, []*azdext.Artifact{playground}, "/tmp", "")
1261+
require.Empty(t, playground.Metadata["note"], "no note-bearing artifact → nothing to augment")
1262+
}
1263+
1264+
// TestAugmentDeployNote_NoServicesIsNoOp covers a partial-state branch:
1265+
// ResolveAfterDeploy short-circuits on len(state.Services) == 0, so the
1266+
// existing static note must survive unchanged.
1267+
func TestAugmentDeployNote_NoServicesIsNoOp(t *testing.T) {
1268+
t.Parallel()
1269+
1270+
artifact := &azdext.Artifact{
1271+
Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT,
1272+
Metadata: map[string]string{
1273+
"label": "Agent endpoint (invocations)",
1274+
"note": "static aka.ms link",
1275+
},
1276+
}
1277+
augmentDeployNote(&nextstep.State{}, []*azdext.Artifact{artifact}, "/tmp", "")
1278+
require.Equal(t, "static aka.ms link", artifact.Metadata["note"])
1279+
}

0 commit comments

Comments
 (0)