m.removeEventListener("scroll",f)}},[r.viewport,r.isPositioned]),l?o.jsx(X2,{...e,ref:d,onAutoScroll:()=>{const{viewport:f,selectedItem:m}=r;f&&m&&(f.scrollTop=f.scrollTop+m.offsetHeight)}}):null});G2.displayName=ip;var X2=w.forwardRef((e,n)=>{const{__scopeSelect:r,onAutoScroll:a,...l}=e,c=Ur("SelectScrollButton",r),d=w.useRef(null),f=Md(r),m=w.useCallback(()=>{d.current!==null&&(window.clearInterval(d.current),d.current=null)},[]);return w.useEffect(()=>()=>m(),[m]),Wt(()=>{f().find(g=>g.ref.current===document.activeElement)?.ref.current?.scrollIntoView({block:"nearest"})},[f]),o.jsx(Ye.div,{"aria-hidden":!0,...l,ref:n,style:{flexShrink:0,...l.style},onPointerDown:ke(l.onPointerDown,()=>{d.current===null&&(d.current=window.setInterval(a,50))}),onPointerMove:ke(l.onPointerMove,()=>{c.onItemLeave?.(),d.current===null&&(d.current=window.setInterval(a,50))}),onPointerLeave:ke(l.onPointerLeave,()=>{m()})})}),qD="SelectSeparator",FD=w.forwardRef((e,n)=>{const{__scopeSelect:r,...a}=e;return o.jsx(Ye.div,{"aria-hidden":!0,...a,ref:n})});FD.displayName=qD;var lp="SelectArrow",YD=w.forwardRef((e,n)=>{const{__scopeSelect:r,...a}=e,l=Rd(r),c=Hr(lp,r),d=Ur(lp,r);return c.open&&d.position==="popper"?o.jsx(Vp,{...l,...a,ref:n}):null});YD.displayName=lp;var GD="SelectBubbleInput",Z2=w.forwardRef(({__scopeSelect:e,value:n,...r},a)=>{const l=w.useRef(null),c=rt(a,l),d=fg(n);return w.useEffect(()=>{const f=l.current;if(!f)return;const m=window.HTMLSelectElement.prototype,g=Object.getOwnPropertyDescriptor(m,"value").set;if(d!==n&&g){const x=new Event("change",{bubbles:!0});g.call(f,n),f.dispatchEvent(x)}},[d,n]),o.jsx(Ye.select,{...r,style:{...GN,...r.style},ref:c,defaultValue:n})});Z2.displayName=GD;function W2(e){return e===""||e===void 0}function K2(e){const n=Zt(e),r=w.useRef(""),a=w.useRef(0),l=w.useCallback(d=>{const f=r.current+d;n(f),(function m(h){r.current=h,window.clearTimeout(a.current),h!==""&&(a.current=window.setTimeout(()=>m(""),1e3))})(f)},[n]),c=w.useCallback(()=>{r.current="",window.clearTimeout(a.current)},[]);return w.useEffect(()=>()=>window.clearTimeout(a.current),[]),[r,l,c]}function Q2(e,n,r){const l=n.length>1&&Array.from(n).every(h=>h===n[0])?n[0]:n,c=r?e.indexOf(r):-1;let d=XD(e,Math.max(c,0));l.length===1&&(d=d.filter(h=>h!==r));const m=d.find(h=>h.textValue.toLowerCase().startsWith(l.toLowerCase()));return m!==r?m:void 0}function XD(e,n){return e.map((r,a)=>e[(n+a)%e.length])}var ZD=C2,WD=T2,KD=M2,QD=R2,JD=D2,e6=O2,t6=$2,n6=B2,s6=V2,r6=F2,o6=Y2,a6=G2;function vg({...e}){return o.jsx(ZD,{"data-slot":"select",...e})}function bg({...e}){return o.jsx(KD,{"data-slot":"select-value",...e})}function wg({className:e,size:n="default",children:r,...a}){return o.jsxs(WD,{"data-slot":"select-trigger","data-size":n,className:We("border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",e),...a,children:[r,o.jsx(QD,{asChild:!0,children:o.jsx(Rt,{className:"size-4 opacity-50"})})]})}function Ng({className:e,children:n,position:r="popper",...a}){return o.jsx(JD,{children:o.jsxs(e6,{"data-slot":"select-content",className:We("bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",r==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",e),position:r,...a,children:[o.jsx(i6,{}),o.jsx(t6,{className:We("p-1",r==="popper"&&"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"),children:n}),o.jsx(l6,{})]})})}function jg({className:e,children:n,...r}){return o.jsxs(n6,{"data-slot":"select-item",className:We("focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",e),...r,children:[o.jsx("span",{className:"absolute right-2 flex size-3.5 items-center justify-center",children:o.jsx(r6,{children:o.jsx(jo,{className:"size-4"})})}),o.jsx(s6,{children:n})]})}function i6({className:e,...n}){return o.jsx(o6,{"data-slot":"select-scroll-up-button",className:We("flex cursor-default items-center justify-center py-1",e),...n,children:o.jsx(rN,{className:"size-4"})})}function l6({className:e,...n}){return o.jsx(a6,{"data-slot":"select-scroll-down-button",className:We("flex cursor-default items-center justify-center py-1",e),...n,children:o.jsx(Rt,{className:"size-4"})})}function io({title:e,icon:n,children:r,className:a=""}){return o.jsxs("div",{className:`border rounded-lg p-4 bg-card ${a}`,children:[o.jsxs("div",{className:"flex items-center gap-2 mb-3",children:[n,o.jsx("h3",{className:"text-sm font-semibold text-foreground",children:e})]}),o.jsx("div",{className:"text-sm text-muted-foreground",children:r})]})}function c6({agent:e,open:n,onOpenChange:r}){const a=e.source==="directory"?o.jsx(aN,{className:"h-4 w-4 text-muted-foreground"}):e.source==="in_memory"?o.jsx(Kh,{className:"h-4 w-4 text-muted-foreground"}):o.jsx(iN,{className:"h-4 w-4 text-muted-foreground"}),l=e.source==="directory"?"Local":e.source==="in_memory"?"In-Memory":"Gallery";return o.jsx(Ir,{open:n,onOpenChange:r,children:o.jsxs(Lr,{className:"max-w-4xl max-h-[90vh] flex flex-col",children:[o.jsxs($r,{className:"px-6 pt-6 flex-shrink-0",children:[o.jsx(Pr,{children:"Agent Details"}),o.jsx(So,{onClose:()=>r(!1)})]}),o.jsxs("div",{className:"px-6 pb-6 overflow-y-auto flex-1",children:[o.jsxs("div",{className:"mb-6",children:[o.jsxs("div",{className:"flex items-center gap-3 mb-2",children:[o.jsx(Vs,{className:"h-6 w-6 text-primary"}),o.jsx("h2",{className:"text-xl font-semibold text-foreground",children:e.name||e.id})]}),e.description&&o.jsx("p",{className:"text-muted-foreground",children:e.description})]}),o.jsx("div",{className:"h-px bg-border mb-6"}),o.jsxs("div",{className:"grid grid-cols-1 md:grid-cols-2 gap-4 mb-4",children:[(e.model_id||e.chat_client_type)&&o.jsx(io,{title:"Model & Client",icon:o.jsx(Vs,{className:"h-4 w-4 text-muted-foreground"}),children:o.jsxs("div",{className:"space-y-1",children:[e.model_id&&o.jsx("div",{className:"font-mono text-foreground",children:e.model_id}),e.chat_client_type&&o.jsxs("div",{className:"text-xs",children:["(",e.chat_client_type,")"]})]})}),o.jsx(io,{title:"Source",icon:a,children:o.jsxs("div",{className:"space-y-1",children:[o.jsx("div",{className:"text-foreground",children:l}),e.module_path&&o.jsx("div",{className:"font-mono text-xs break-all",children:e.module_path})]})}),o.jsx(io,{title:"Environment",icon:e.has_env?o.jsx(kl,{className:"h-4 w-4 text-orange-500"}):o.jsx(yd,{className:"h-4 w-4 text-green-500"}),className:"md:col-span-2",children:o.jsx("div",{className:e.has_env?"text-orange-600 dark:text-orange-400":"text-green-600 dark:text-green-400",children:e.has_env?"Requires environment variables":"No environment variables required"})})]}),e.instructions&&o.jsx(io,{title:"Instructions",icon:o.jsx(qs,{className:"h-4 w-4 text-muted-foreground"}),className:"mb-4",children:o.jsx("div",{className:"text-sm text-foreground leading-relaxed whitespace-pre-wrap",children:e.instructions})}),o.jsxs("div",{className:"grid grid-cols-1 md:grid-cols-2 gap-4",children:[e.tools&&e.tools.length>0&&o.jsx(io,{title:`Tools (${e.tools.length})`,icon:o.jsx(Uu,{className:"h-4 w-4 text-muted-foreground"}),children:o.jsx("ul",{className:"space-y-1",children:e.tools.map((c,d)=>o.jsxs("li",{className:"font-mono text-xs text-foreground",children:["• ",c]},d))})}),e.middleware&&e.middleware.length>0&&o.jsx(io,{title:`Middleware (${e.middleware.length})`,icon:o.jsx(Uu,{className:"h-4 w-4 text-muted-foreground"}),children:o.jsx("ul",{className:"space-y-1",children:e.middleware.map((c,d)=>o.jsxs("li",{className:"font-mono text-xs text-foreground",children:["• ",c]},d))})}),e.context_providers&&e.context_providers.length>0&&o.jsx(io,{title:`Context Providers (${e.context_providers.length})`,icon:o.jsx(Kh,{className:"h-4 w-4 text-muted-foreground"}),className:!e.middleware||e.middleware.length===0?"md:col-start-2":"",children:o.jsx("ul",{className:"space-y-1",children:e.context_providers.map((c,d)=>o.jsxs("li",{className:"font-mono text-xs text-foreground",children:["• ",c]},d))})})]})]})]})})}function u6({item:e,toolCalls:n=[],toolResults:r=[]}){const[a,l]=w.useState(!1),[c,d]=w.useState(!1),[f,m]=w.useState(!1),h=le(y=>y.showToolCalls),g=()=>e.type==="message"?e.content.filter(y=>y.type==="text").map(y=>y.text).join(`
+`), language: h
+}, a.length)); continue
+ } const d = c.match(/^(#{1,6})\s+(.+)$/); if (d) { const f = d[1].length, m = d[2], g = `${["text-2xl", "text-xl", "text-lg", "text-base", "text-sm", "text-sm"][f - 1]} font-semibold mt-4 mb-2 first:mt-0 break-words`, x = f === 1 ? o.jsx("h1", { className: g, children: wn(m) }, a.length) : f === 2 ? o.jsx("h2", { className: g, children: wn(m) }, a.length) : f === 3 ? o.jsx("h3", { className: g, children: wn(m) }, a.length) : f === 4 ? o.jsx("h4", { className: g, children: wn(m) }, a.length) : f === 5 ? o.jsx("h5", { className: g, children: wn(m) }, a.length) : o.jsx("h6", { className: g, children: wn(m) }, a.length); a.push(x), l++; continue } if (c.match(/^[\s]*[-*+]\s+/)) { const f = []; for (; l < r.length && r[l].match(/^[\s]*[-*+]\s+/);) { const m = r[l].replace(/^[\s]*[-*+]\s+/, ""); f.push(m), l++ } a.push(o.jsx("ul", { className: "my-2 ml-4 list-disc space-y-1 break-words", children: f.map((m, h) => o.jsx("li", { className: "text-sm break-words", children: wn(m) }, h)) }, a.length)); continue } if (c.match(/^[\s]*\d+\.\s+/)) { const f = []; for (; l < r.length && r[l].match(/^[\s]*\d+\.\s+/);) { const m = r[l].replace(/^[\s]*\d+\.\s+/, ""); f.push(m), l++ } a.push(o.jsx("ol", { className: "my-2 ml-4 list-decimal space-y-1 break-words", children: f.map((m, h) => o.jsx("li", { className: "text-sm break-words", children: wn(m) }, h)) }, a.length)); continue } if (c.trim().startsWith("|") && c.trim().endsWith("|")) { const f = []; for (; l < r.length && r[l].trim().startsWith("|") && r[l].trim().endsWith("|");)f.push(r[l].trim()), l++; if (f.length >= 2) { const m = f[0].split("|").slice(1, -1).map(g => g.trim()); if (f[1].match(/^\|[\s\-:|]+\|$/)) { const g = f.slice(2).map(x => x.split("|").slice(1, -1).map(y => y.trim())); a.push(o.jsx("div", { className: "my-3 overflow-x-auto", children: o.jsxs("table", { className: "min-w-full border border-foreground/10 text-sm", children: [o.jsx("thead", { className: "bg-foreground/5", children: o.jsx("tr", { children: m.map((x, y) => o.jsx("th", { className: "border-b border-foreground/10 px-3 py-2 text-left font-semibold break-words", children: wn(x) }, y)) }) }), o.jsx("tbody", { children: g.map((x, y) => o.jsx("tr", { className: "border-b border-foreground/5 last:border-b-0", children: x.map((b, j) => o.jsx("td", { className: "px-3 py-2 border-r border-foreground/5 last:border-r-0 break-words", children: wn(b) }, j)) }, y)) })] }) }, a.length)); continue } } for (const m of f) a.push(o.jsx("p", { className: "my-1", children: wn(m) }, a.length)); continue } if (c.trim().startsWith(">")) { const f = []; for (; l < r.length && r[l].trim().startsWith(">");)f.push(r[l].replace(/^>\s?/, "")), l++; a.push(o.jsx("blockquote", { className: "my-2 pl-4 border-l-4 border-current/30 opacity-80 italic break-words", children: f.map((m, h) => o.jsx("div", { className: "break-words", children: wn(m) }, h)) }, a.length)); continue } if (c.match(/^[\s]*[-*_]{3,}[\s]*$/)) { a.push(o.jsx("hr", { className: "my-4 border-t border-border" }, a.length)), l++; continue } if (c.trim() === "") { a.push(o.jsx("div", { className: "h-2" }, a.length)), l++; continue } a.push(o.jsx("p", { className: "my-1 break-words", children: wn(c) }, a.length)), l++
+ } return o.jsx("div", { className: `markdown-content break-words ${n}`, children: a })
+} function wn(e) { const n = []; let r = e, a = 0; for (; r.length > 0;) { const l = r.match(/`([^`]+)`/); if (l && l.index !== void 0) { l.index > 0 && n.push(o.jsx("span", { children: nl(r.slice(0, l.index)) }, a++)), n.push(o.jsx("code", { className: "px-1.5 py-0.5 bg-foreground/10 rounded text-xs font-mono border border-foreground/20", children: l[1] }, a++)), r = r.slice(l.index + l[0].length); continue } n.push(o.jsx("span", { children: nl(r) }, a++)); break } return n } function nl(e) { const n = []; let r = e, a = 0; for (; r.length > 0;) { const l = [{ regex: /\*\*\[([^\]]+)\]\(([^)]+)\)\*\*/, component: "strong-link" }, { regex: /__\[([^\]]+)\]\(([^)]+)\)__/, component: "strong-link" }, { regex: /\*\[([^\]]+)\]\(([^)]+)\)\*/, component: "em-link" }, { regex: /_\[([^\]]+)\]\(([^)]+)\)_/, component: "em-link" }, { regex: /\[([^\]]+)\]\(([^)]+)\)/, component: "link" }, { regex: /\*\*(.+?)\*\*/, component: "strong" }, { regex: /__(.+?)__/, component: "strong" }, { regex: /\*(.+?)\*/, component: "em" }, { regex: /_(.+?)_/, component: "em" }]; let c = !1; for (const d of l) { const f = r.match(d.regex); if (f && f.index !== void 0) { if (f.index > 0 && n.push(r.slice(0, f.index)), d.component === "strong") n.push(o.jsx("strong", { className: "font-semibold", children: f[1] }, a++)); else if (d.component === "em") n.push(o.jsx("em", { className: "italic", children: f[1] }, a++)); else if (d.component === "strong-link") { const m = f[1], h = f[2], g = nl(m); n.push(o.jsx("strong", { className: "font-semibold", children: o.jsx("a", { href: h, target: "_blank", rel: "noopener noreferrer", className: "text-primary hover:underline break-words", children: g }) }, a++)) } else if (d.component === "em-link") { const m = f[1], h = f[2], g = nl(m); n.push(o.jsx("em", { className: "italic", children: o.jsx("a", { href: h, target: "_blank", rel: "noopener noreferrer", className: "text-primary hover:underline break-words", children: g }) }, a++)) } else if (d.component === "link") { const m = f[1], h = f[2], g = nl(m); n.push(o.jsx("a", { href: h, target: "_blank", rel: "noopener noreferrer", className: "text-primary hover:underline break-words", children: g }, a++)) } r = r.slice(f.index + f[0].length), c = !0; break } } if (!c) { r.length > 0 && n.push(r); break } } return n } function gD({ content: e, className: n, isStreaming: r }) { if (e.type !== "text" && e.type !== "input_text" && e.type !== "output_text") return null; const a = e.text; return o.jsxs("div", { className: `break-words ${n || ""}`, children: [o.jsx(pD, { content: a }), r && a.length > 0 && o.jsx("span", { className: "ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" })] }) } function xD({ content: e, className: n }) { const [r, a] = w.useState(!1), [l, c] = w.useState(!1); if (e.type !== "input_image" && e.type !== "output_image") return null; const d = e.image_url; return r ? o.jsx("div", { className: `my-2 p-3 border rounded-lg bg-muted ${n || ""}`, children: o.jsxs("div", { className: "flex items-center gap-2 text-sm text-muted-foreground", children: [o.jsx(qs, { className: "h-4 w-4" }), o.jsx("span", { children: "Image could not be loaded" })] }) }) : o.jsxs("div", { className: `my-2 ${n || ""}`, children: [o.jsx("img", { src: d, alt: "Uploaded image", className: `rounded-lg border max-w-full transition-all cursor-pointer ${l ? "max-h-none" : "max-h-64"}`, onClick: () => c(!l), onError: () => a(!0) }), l && o.jsx("div", { className: "text-xs text-muted-foreground mt-1", children: "Click to collapse" })] }) } function yD(e, n) { const [r, a] = w.useState(null); return w.useEffect(() => { if (!e) { a(null); return } try { let l; if (e.startsWith("data:")) { const h = e.split(","); if (h.length !== 2) { a(null); return } l = h[1] } else l = e; const c = atob(l), d = new Uint8Array(c.length); for (let h = 0; h < c.length; h++)d[h] = c.charCodeAt(h); const f = new Blob([d], { type: n }), m = URL.createObjectURL(f); return a(m), () => { URL.revokeObjectURL(m) } } catch (l) { console.error("Failed to convert base64 to blob URL:", l), a(null) } }, [e, n]), r } function vD({ content: e, className: n }) { const [r, a] = w.useState(!0), l = e.type === "input_file" || e.type === "output_file", c = l ? e.file_url || e.file_data : void 0, d = l ? e.filename || "file" : void 0, f = d?.toLowerCase().endsWith(".pdf") || c?.includes("application/pdf"), m = d?.toLowerCase().match(/\.(mp3|wav|m4a|ogg|flac|aac)$/), h = l && f ? e.file_data || e.file_url : void 0, g = yD(h, "application/pdf"); if (!l) return null; const x = g || c, y = () => { x && window.open(x, "_blank") }; return f && c ? o.jsxs("div", { className: `my-2 ${n || ""}`, children: [o.jsxs("div", { className: "flex items-center gap-2 mb-2 px-1", children: [o.jsx(qs, { className: "h-4 w-4 text-red-500" }), o.jsx("span", { className: "text-sm font-medium truncate flex-1", children: d }), o.jsx("button", { onClick: () => a(!r), className: "text-xs text-muted-foreground hover:text-foreground flex items-center gap-1", children: r ? o.jsxs(o.Fragment, { children: [o.jsx(Rt, { className: "h-3 w-3" }), "Collapse"] }) : o.jsxs(o.Fragment, { children: [o.jsx(en, { className: "h-3 w-3" }), "Expand"] }) })] }), r && o.jsxs("div", { className: "border rounded-lg p-6 bg-muted/50 flex flex-col items-center justify-center gap-4", children: [o.jsx(qs, { className: "h-16 w-16 text-red-400" }), o.jsxs("div", { className: "text-center", children: [o.jsx("p", { className: "text-sm font-medium mb-1", children: d }), o.jsx("p", { className: "text-xs text-muted-foreground", children: "PDF Document" })] }), o.jsxs("div", { className: "flex gap-3", children: [o.jsx("button", { onClick: y, className: "text-sm bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-2 px-4 py-2 rounded-md transition-colors", children: "Open in new tab" }), o.jsxs("a", { href: x || c, download: d, className: "text-sm text-foreground hover:bg-accent flex items-center gap-2 px-4 py-2 border rounded-md transition-colors", children: [o.jsx(Pu, { className: "h-4 w-4" }), "Download"] })] })] })] }) : m && c ? o.jsxs("div", { className: `my-2 p-3 border rounded-lg ${n || ""}`, children: [o.jsxs("div", { className: "flex items-center gap-2 mb-2", children: [o.jsx(lN, { className: "h-4 w-4 text-muted-foreground" }), o.jsx("span", { className: "text-sm font-medium", children: d })] }), o.jsxs("audio", { controls: !0, className: "w-full", children: [o.jsx("source", { src: c }), "Your browser does not support audio playback."] })] }) : o.jsx("div", { className: `my-2 p-3 border rounded-lg bg-muted ${n || ""}`, children: o.jsxs("div", { className: "flex items-center justify-between", children: [o.jsxs("div", { className: "flex items-center gap-2", children: [o.jsx(qs, { className: "h-4 w-4 text-muted-foreground" }), o.jsx("span", { className: "text-sm", children: d })] }), c && o.jsxs("a", { href: c, download: d, className: "text-xs text-primary hover:underline flex items-center gap-1", children: [o.jsx(Pu, { className: "h-3 w-3" }), "Download"] })] }) }) } function bD({ content: e, className: n }) { const [r, a] = w.useState(!1); if (e.type !== "output_data") return null; const l = e.data, c = e.mime_type, d = e.description; let f = l; try { const m = JSON.parse(l); f = JSON.stringify(m, null, 2) } catch { } return o.jsxs("div", { className: `my-2 p-3 border rounded-lg bg-muted ${n || ""}`, children: [o.jsxs("div", { className: "flex items-center gap-2 cursor-pointer", onClick: () => a(!r), children: [o.jsx(qs, { className: "h-4 w-4 text-muted-foreground" }), o.jsx("span", { className: "text-sm font-medium", children: d || "Data Output" }), o.jsx("span", { className: "text-xs text-muted-foreground ml-auto", children: c }), r ? o.jsx(Rt, { className: "h-4 w-4 text-muted-foreground" }) : o.jsx(en, { className: "h-4 w-4 text-muted-foreground" })] }), r && o.jsx("pre", { className: "mt-2 text-xs overflow-auto max-h-64 bg-background p-2 rounded border font-mono", children: f })] }) } function wD({ content: e, className: n }) { const [r, a] = w.useState(!1); if (e.type !== "function_approval_request") return null; const { status: l, function_call: c } = e, f = { pending: { icon: Jp, label: "Awaiting approval", iconClass: "text-amber-600 dark:text-amber-400" }, approved: { icon: jo, label: "Approved", iconClass: "text-green-600 dark:text-green-400" }, rejected: { icon: Ea, label: "Rejected", iconClass: "text-red-600 dark:text-red-400" } }[l], m = f.icon; let h; try { h = typeof c.arguments == "string" ? JSON.parse(c.arguments) : c.arguments } catch { h = c.arguments } return o.jsxs("div", { className: n, children: [o.jsxs("button", { onClick: () => a(!r), className: "flex items-center gap-2 px-2 py-1 text-xs rounded hover:bg-muted/50 transition-colors w-fit", children: [o.jsx(m, { className: `h-3 w-3 ${f.iconClass}` }), o.jsx("span", { className: "text-muted-foreground font-mono", children: c.name }), o.jsx("span", { className: `text-xs ${f.iconClass}`, children: f.label }), r ? o.jsx("span", { className: "text-xs text-muted-foreground", children: "▼" }) : o.jsx("span", { className: "text-xs text-muted-foreground", children: "▶" })] }), r && o.jsx("div", { className: "ml-5 mt-1 text-xs font-mono text-muted-foreground border-l-2 border-muted pl-3", children: o.jsx("pre", { className: "whitespace-pre-wrap break-all", children: JSON.stringify(h, null, 2) }) })] }) } function ND({ content: e, className: n, isStreaming: r }) { switch (e.type) { case "text": case "input_text": case "output_text": return o.jsx(gD, { content: e, className: n, isStreaming: r }); case "input_image": case "output_image": return o.jsx(xD, { content: e, className: n }); case "input_file": case "output_file": return o.jsx(vD, { content: e, className: n }); case "output_data": return o.jsx(bD, { content: e, className: n }); case "function_approval_request": return o.jsx(wD, { content: e, className: n }); default: return null } } function jD({ name: e, arguments: n, className: r }) { const [a, l] = w.useState(!1); let c; try { c = typeof n == "string" ? JSON.parse(n) : n } catch { c = n } return o.jsxs("div", { className: `my-2 p-3 border rounded bg-blue-50 dark:bg-blue-950/20 ${r || ""}`, children: [o.jsxs("div", { className: "flex items-center gap-2 cursor-pointer", onClick: () => l(!a), children: [o.jsx(oN, { className: "h-4 w-4 text-blue-600 dark:text-blue-400" }), o.jsxs("span", { className: "text-sm font-medium text-blue-800 dark:text-blue-300", children: ["Function Call: ", e] }), a ? o.jsx(Rt, { className: "h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto" }) : o.jsx(en, { className: "h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto" })] }), a && o.jsxs("div", { className: "mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border", children: [o.jsx("div", { className: "text-blue-600 dark:text-blue-400 mb-1", children: "Arguments:" }), o.jsx("pre", { className: "whitespace-pre-wrap", children: JSON.stringify(c, null, 2) })] })] }) } function SD({ output: e, call_id: n, className: r }) { const [a, l] = w.useState(!1); let c; try { c = typeof e == "string" ? JSON.parse(e) : e } catch { c = e } return o.jsxs("div", { className: `my-2 p-3 border rounded bg-green-50 dark:bg-green-950/20 ${r || ""}`, children: [o.jsxs("div", { className: "flex items-center gap-2 cursor-pointer", onClick: () => l(!a), children: [o.jsx(oN, { className: "h-4 w-4 text-green-600 dark:text-green-400" }), o.jsx("span", { className: "text-sm font-medium text-green-800 dark:text-green-300", children: "Function Result" }), a ? o.jsx(Rt, { className: "h-4 w-4 text-green-600 dark:text-green-400 ml-auto" }) : o.jsx(en, { className: "h-4 w-4 text-green-600 dark:text-green-400 ml-auto" })] }), a && o.jsxs("div", { className: "mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border", children: [o.jsx("div", { className: "text-green-600 dark:text-green-400 mb-1", children: "Output:" }), o.jsx("pre", { className: "whitespace-pre-wrap", children: JSON.stringify(c, null, 2) }), o.jsxs("div", { className: "text-gray-500 text-[10px] mt-2", children: ["Call ID: ", n] })] })] }) } function _D({ item: e, className: n }) { if (e.type === "message") { const r = e.status === "in_progress", a = e.content.length > 0; return o.jsxs("div", { className: n, children: [e.content.map((l, c) => o.jsx(ND, { content: l, className: c > 0 ? "mt-2" : "", isStreaming: r }, c)), r && !a && o.jsx("div", { className: "flex items-center space-x-1", children: o.jsxs("div", { className: "flex space-x-1", children: [o.jsx("div", { className: "h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" }), o.jsx("div", { className: "h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" }), o.jsx("div", { className: "h-2 w-2 animate-bounce rounded-full bg-current" })] }) })] }) } return e.type === "function_call" ? o.jsx(jD, { name: e.name, arguments: e.arguments, className: n }) : e.type === "function_call_output" ? o.jsx(SD, { output: e.output, call_id: e.call_id, className: n }) : null } var ED = [" ", "Enter", "ArrowUp", "ArrowDown"], CD = [" ", "Enter"], go = "Select", [Ad, Md, kD] = Tp(go), [Ba, t$] = Kn(go, [kD, Ua]), Rd = Ua(), [TD, Hr] = Ba(go), [AD, MD] = Ba(go), C2 = e => { const { __scopeSelect: n, children: r, open: a, defaultOpen: l, onOpenChange: c, value: d, defaultValue: f, onValueChange: m, dir: h, name: g, autoComplete: x, disabled: y, required: b, form: j } = e, N = Rd(n), [S, _] = w.useState(null), [A, E] = w.useState(null), [M, T] = w.useState(!1), D = jl(h), [z, H] = Ar({ prop: a, defaultProp: l ?? !1, onChange: c, caller: go }), [q, X] = Ar({ prop: d, defaultProp: f, onChange: m, caller: go }), W = w.useRef(null), G = S ? j || !!S.closest("form") : !0, [ne, B] = w.useState(new Set), U = Array.from(ne).map(R => R.props.value).join(";"); return o.jsx(Hp, { ...N, children: o.jsxs(TD, { required: b, scope: n, trigger: S, onTriggerChange: _, valueNode: A, onValueNodeChange: E, valueNodeHasChildren: M, onValueNodeHasChildrenChange: T, contentId: Mr(), value: q, onValueChange: X, open: z, onOpenChange: H, dir: D, triggerPointerDownPosRef: W, disabled: y, children: [o.jsx(Ad.Provider, { scope: n, children: o.jsx(AD, { scope: e.__scopeSelect, onNativeOptionAdd: w.useCallback(R => { B(L => new Set(L).add(R)) }, []), onNativeOptionRemove: w.useCallback(R => { B(L => { const I = new Set(L); return I.delete(R), I }) }, []), children: r }) }), G ? o.jsxs(Z2, { "aria-hidden": !0, required: b, tabIndex: -1, name: g, autoComplete: x, value: q, onChange: R => X(R.target.value), disabled: y, form: j, children: [q === void 0 ? o.jsx("option", { value: "" }) : null, Array.from(ne)] }, U) : null] }) }) }; C2.displayName = go; var k2 = "SelectTrigger", T2 = w.forwardRef((e, n) => { const { __scopeSelect: r, disabled: a = !1, ...l } = e, c = Rd(r), d = Hr(k2, r), f = d.disabled || a, m = rt(n, d.onTriggerChange), h = Md(r), g = w.useRef("touch"), [x, y, b] = K2(N => { const S = h().filter(E => !E.disabled), _ = S.find(E => E.value === d.value), A = Q2(S, N, _); A !== void 0 && d.onValueChange(A.value) }), j = N => { f || (d.onOpenChange(!0), b()), N && (d.triggerPointerDownPosRef.current = { x: Math.round(N.pageX), y: Math.round(N.pageY) }) }; return o.jsx(Up, { asChild: !0, ...c, children: o.jsx(Ye.button, { type: "button", role: "combobox", "aria-controls": d.contentId, "aria-expanded": d.open, "aria-required": d.required, "aria-autocomplete": "none", dir: d.dir, "data-state": d.open ? "open" : "closed", disabled: f, "data-disabled": f ? "" : void 0, "data-placeholder": W2(d.value) ? "" : void 0, ...l, ref: m, onClick: ke(l.onClick, N => { N.currentTarget.focus(), g.current !== "mouse" && j(N) }), onPointerDown: ke(l.onPointerDown, N => { g.current = N.pointerType; const S = N.target; S.hasPointerCapture(N.pointerId) && S.releasePointerCapture(N.pointerId), N.button === 0 && N.ctrlKey === !1 && N.pointerType === "mouse" && (j(N), N.preventDefault()) }), onKeyDown: ke(l.onKeyDown, N => { const S = x.current !== ""; !(N.ctrlKey || N.altKey || N.metaKey) && N.key.length === 1 && y(N.key), !(S && N.key === " ") && ED.includes(N.key) && (j(), N.preventDefault()) }) }) }) }); T2.displayName = k2; var A2 = "SelectValue", M2 = w.forwardRef((e, n) => { const { __scopeSelect: r, className: a, style: l, children: c, placeholder: d = "", ...f } = e, m = Hr(A2, r), { onValueNodeHasChildrenChange: h } = m, g = c !== void 0, x = rt(n, m.onValueNodeChange); return Wt(() => { h(g) }, [h, g]), o.jsx(Ye.span, { ...f, ref: x, style: { pointerEvents: "none" }, children: W2(m.value) ? o.jsx(o.Fragment, { children: d }) : c }) }); M2.displayName = A2; var RD = "SelectIcon", R2 = w.forwardRef((e, n) => { const { __scopeSelect: r, children: a, ...l } = e; return o.jsx(Ye.span, { "aria-hidden": !0, ...l, ref: n, children: a || "▼" }) }); R2.displayName = RD; var DD = "SelectPortal", D2 = e => o.jsx(fd, { asChild: !0, ...e }); D2.displayName = DD; var xo = "SelectContent", O2 = w.forwardRef((e, n) => { const r = Hr(xo, e.__scopeSelect), [a, l] = w.useState(); if (Wt(() => { l(new DocumentFragment) }, []), !r.open) { const c = a; return c ? Nl.createPortal(o.jsx(z2, { scope: e.__scopeSelect, children: o.jsx(Ad.Slot, { scope: e.__scopeSelect, children: o.jsx("div", { children: e.children }) }) }), c) : null } return o.jsx(I2, { ...e, ref: n }) }); O2.displayName = xo; var qn = 10, [z2, Ur] = Ba(xo), OD = "SelectContentImpl", zD = ja("SelectContent.RemoveScroll"), I2 = w.forwardRef((e, n) => { const { __scopeSelect: r, position: a = "item-aligned", onCloseAutoFocus: l, onEscapeKeyDown: c, onPointerDownOutside: d, side: f, sideOffset: m, align: h, alignOffset: g, arrowPadding: x, collisionBoundary: y, collisionPadding: b, sticky: j, hideWhenDetached: N, avoidCollisions: S, ..._ } = e, A = Hr(xo, r), [E, M] = w.useState(null), [T, D] = w.useState(null), z = rt(n, ee => M(ee)), [H, q] = w.useState(null), [X, W] = w.useState(null), G = Md(r), [ne, B] = w.useState(!1), U = w.useRef(!1); w.useEffect(() => { if (E) return h1(E) }, [E]), Lw(); const R = w.useCallback(ee => { const [ie, ...ge] = G().map(ve => ve.ref.current), [Ee] = ge.slice(-1), Ne = document.activeElement; for (const ve of ee) if (ve === Ne || (ve?.scrollIntoView({ block: "nearest" }), ve === ie && T && (T.scrollTop = 0), ve === Ee && T && (T.scrollTop = T.scrollHeight), ve?.focus(), document.activeElement !== Ne)) return }, [G, T]), L = w.useCallback(() => R([H, E]), [R, H, E]); w.useEffect(() => { ne && L() }, [ne, L]); const { onOpenChange: I, triggerPointerDownPosRef: P } = A; w.useEffect(() => { if (E) { let ee = { x: 0, y: 0 }; const ie = Ee => { ee = { x: Math.abs(Math.round(Ee.pageX) - (P.current?.x ?? 0)), y: Math.abs(Math.round(Ee.pageY) - (P.current?.y ?? 0)) } }, ge = Ee => { ee.x <= 10 && ee.y <= 10 ? Ee.preventDefault() : E.contains(Ee.target) || I(!1), document.removeEventListener("pointermove", ie), P.current = null }; return P.current !== null && (document.addEventListener("pointermove", ie), document.addEventListener("pointerup", ge, { capture: !0, once: !0 })), () => { document.removeEventListener("pointermove", ie), document.removeEventListener("pointerup", ge, { capture: !0 }) } } }, [E, I, P]), w.useEffect(() => { const ee = () => I(!1); return window.addEventListener("blur", ee), window.addEventListener("resize", ee), () => { window.removeEventListener("blur", ee), window.removeEventListener("resize", ee) } }, [I]); const [C, $] = K2(ee => { const ie = G().filter(Ne => !Ne.disabled), ge = ie.find(Ne => Ne.ref.current === document.activeElement), Ee = Q2(ie, ee, ge); Ee && setTimeout(() => Ee.ref.current.focus()) }), Y = w.useCallback((ee, ie, ge) => { const Ee = !U.current && !ge; (A.value !== void 0 && A.value === ie || Ee) && (q(ee), Ee && (U.current = !0)) }, [A.value]), V = w.useCallback(() => E?.focus(), [E]), J = w.useCallback((ee, ie, ge) => { const Ee = !U.current && !ge; (A.value !== void 0 && A.value === ie || Ee) && W(ee) }, [A.value]), ce = a === "popper" ? rp : L2, fe = ce === rp ? { side: f, sideOffset: m, align: h, alignOffset: g, arrowPadding: x, collisionBoundary: y, collisionPadding: b, sticky: j, hideWhenDetached: N, avoidCollisions: S } : {}; return o.jsx(z2, { scope: r, content: E, viewport: T, onViewportChange: D, itemRefCallback: Y, selectedItem: H, onItemLeave: V, itemTextRefCallback: J, focusSelectedItem: L, selectedItemText: X, position: a, isPositioned: ne, searchRef: C, children: o.jsx(qp, { as: zD, allowPinchZoom: !0, children: o.jsx(Ap, { asChild: !0, trapped: A.open, onMountAutoFocus: ee => { ee.preventDefault() }, onUnmountAutoFocus: ke(l, ee => { A.trigger?.focus({ preventScroll: !0 }), ee.preventDefault() }), children: o.jsx(id, { asChild: !0, disableOutsidePointerEvents: !0, onEscapeKeyDown: c, onPointerDownOutside: d, onFocusOutside: ee => ee.preventDefault(), onDismiss: () => A.onOpenChange(!1), children: o.jsx(ce, { role: "listbox", id: A.contentId, "data-state": A.open ? "open" : "closed", dir: A.dir, onContextMenu: ee => ee.preventDefault(), ..._, ...fe, onPlaced: () => B(!0), ref: z, style: { display: "flex", flexDirection: "column", outline: "none", ..._.style }, onKeyDown: ke(_.onKeyDown, ee => { const ie = ee.ctrlKey || ee.altKey || ee.metaKey; if (ee.key === "Tab" && ee.preventDefault(), !ie && ee.key.length === 1 && $(ee.key), ["ArrowUp", "ArrowDown", "Home", "End"].includes(ee.key)) { let Ee = G().filter(Ne => !Ne.disabled).map(Ne => Ne.ref.current); if (["ArrowUp", "End"].includes(ee.key) && (Ee = Ee.slice().reverse()), ["ArrowUp", "ArrowDown"].includes(ee.key)) { const Ne = ee.target, ve = Ee.indexOf(Ne); Ee = Ee.slice(ve + 1) } setTimeout(() => R(Ee)), ee.preventDefault() } }) }) }) }) }) }) }); I2.displayName = OD; var ID = "SelectItemAlignedPosition", L2 = w.forwardRef((e, n) => { const { __scopeSelect: r, onPlaced: a, ...l } = e, c = Hr(xo, r), d = Ur(xo, r), [f, m] = w.useState(null), [h, g] = w.useState(null), x = rt(n, z => g(z)), y = Md(r), b = w.useRef(!1), j = w.useRef(!0), { viewport: N, selectedItem: S, selectedItemText: _, focusSelectedItem: A } = d, E = w.useCallback(() => { if (c.trigger && c.valueNode && f && h && N && S && _) { const z = c.trigger.getBoundingClientRect(), H = h.getBoundingClientRect(), q = c.valueNode.getBoundingClientRect(), X = _.getBoundingClientRect(); if (c.dir !== "rtl") { const Ne = X.left - H.left, ve = q.left - Ne, ze = z.left - ve, re = z.width + ze, Q = Math.max(re, H.width), me = window.innerWidth - qn, be = tp(ve, [qn, Math.max(qn, me - Q)]); f.style.minWidth = re + "px", f.style.left = be + "px" } else { const Ne = H.right - X.right, ve = window.innerWidth - q.right - Ne, ze = window.innerWidth - z.right - ve, re = z.width + ze, Q = Math.max(re, H.width), me = window.innerWidth - qn, be = tp(ve, [qn, Math.max(qn, me - Q)]); f.style.minWidth = re + "px", f.style.right = be + "px" } const W = y(), G = window.innerHeight - qn * 2, ne = N.scrollHeight, B = window.getComputedStyle(h), U = parseInt(B.borderTopWidth, 10), R = parseInt(B.paddingTop, 10), L = parseInt(B.borderBottomWidth, 10), I = parseInt(B.paddingBottom, 10), P = U + R + ne + I + L, C = Math.min(S.offsetHeight * 5, P), $ = window.getComputedStyle(N), Y = parseInt($.paddingTop, 10), V = parseInt($.paddingBottom, 10), J = z.top + z.height / 2 - qn, ce = G - J, fe = S.offsetHeight / 2, ee = S.offsetTop + fe, ie = U + R + ee, ge = P - ie; if (ie <= J) { const Ne = W.length > 0 && S === W[W.length - 1].ref.current; f.style.bottom = "0px"; const ve = h.clientHeight - N.offsetTop - N.offsetHeight, ze = Math.max(ce, fe + (Ne ? V : 0) + ve + L), re = ie + ze; f.style.height = re + "px" } else { const Ne = W.length > 0 && S === W[0].ref.current; f.style.top = "0px"; const ze = Math.max(J, U + N.offsetTop + (Ne ? Y : 0) + fe) + ge; f.style.height = ze + "px", N.scrollTop = ie - J + N.offsetTop } f.style.margin = `${qn}px 0`, f.style.minHeight = C + "px", f.style.maxHeight = G + "px", a?.(), requestAnimationFrame(() => b.current = !0) } }, [y, c.trigger, c.valueNode, f, h, N, S, _, c.dir, a]); Wt(() => E(), [E]); const [M, T] = w.useState(); Wt(() => { h && T(window.getComputedStyle(h).zIndex) }, [h]); const D = w.useCallback(z => { z && j.current === !0 && (E(), A?.(), j.current = !1) }, [E, A]); return o.jsx($D, { scope: r, contentWrapper: f, shouldExpandOnScrollRef: b, onScrollButtonChange: D, children: o.jsx("div", { ref: m, style: { display: "flex", flexDirection: "column", position: "fixed", zIndex: M }, children: o.jsx(Ye.div, { ...l, ref: x, style: { boxSizing: "border-box", maxHeight: "100%", ...l.style } }) }) }) }); L2.displayName = ID; var LD = "SelectPopperPosition", rp = w.forwardRef((e, n) => { const { __scopeSelect: r, align: a = "start", collisionPadding: l = qn, ...c } = e, d = Rd(r); return o.jsx(Bp, { ...d, ...c, ref: n, align: a, collisionPadding: l, style: { boxSizing: "border-box", ...c.style, "--radix-select-content-transform-origin": "var(--radix-popper-transform-origin)", "--radix-select-content-available-width": "var(--radix-popper-available-width)", "--radix-select-content-available-height": "var(--radix-popper-available-height)", "--radix-select-trigger-width": "var(--radix-popper-anchor-width)", "--radix-select-trigger-height": "var(--radix-popper-anchor-height)" } }) }); rp.displayName = LD; var [$D, yg] = Ba(xo, {}), op = "SelectViewport", $2 = w.forwardRef((e, n) => { const { __scopeSelect: r, nonce: a, ...l } = e, c = Ur(op, r), d = yg(op, r), f = rt(n, c.onViewportChange), m = w.useRef(0); return o.jsxs(o.Fragment, { children: [o.jsx("style", { dangerouslySetInnerHTML: { __html: "[data-radix-select-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}[data-radix-select-viewport]::-webkit-scrollbar{display:none}" }, nonce: a }), o.jsx(Ad.Slot, { scope: r, children: o.jsx(Ye.div, { "data-radix-select-viewport": "", role: "presentation", ...l, ref: f, style: { position: "relative", flex: 1, overflow: "hidden auto", ...l.style }, onScroll: ke(l.onScroll, h => { const g = h.currentTarget, { contentWrapper: x, shouldExpandOnScrollRef: y } = d; if (y?.current && x) { const b = Math.abs(m.current - g.scrollTop); if (b > 0) { const j = window.innerHeight - qn * 2, N = parseFloat(x.style.minHeight), S = parseFloat(x.style.height), _ = Math.max(N, S); if (_ < j) { const A = _ + b, E = Math.min(j, A), M = A - E; x.style.height = E + "px", x.style.bottom === "0px" && (g.scrollTop = M > 0 ? M : 0, x.style.justifyContent = "flex-end") } } } m.current = g.scrollTop }) }) })] }) }); $2.displayName = op; var P2 = "SelectGroup", [PD, HD] = Ba(P2), UD = w.forwardRef((e, n) => { const { __scopeSelect: r, ...a } = e, l = Mr(); return o.jsx(PD, { scope: r, id: l, children: o.jsx(Ye.div, { role: "group", "aria-labelledby": l, ...a, ref: n }) }) }); UD.displayName = P2; var H2 = "SelectLabel", BD = w.forwardRef((e, n) => { const { __scopeSelect: r, ...a } = e, l = HD(H2, r); return o.jsx(Ye.div, { id: l.id, ...a, ref: n }) }); BD.displayName = H2; var Xu = "SelectItem", [VD, U2] = Ba(Xu), B2 = w.forwardRef((e, n) => { const { __scopeSelect: r, value: a, disabled: l = !1, textValue: c, ...d } = e, f = Hr(Xu, r), m = Ur(Xu, r), h = f.value === a, [g, x] = w.useState(c ?? ""), [y, b] = w.useState(!1), j = rt(n, A => m.itemRefCallback?.(A, a, l)), N = Mr(), S = w.useRef("touch"), _ = () => { l || (f.onValueChange(a), f.onOpenChange(!1)) }; if (a === "") throw new Error("A must have a value prop that is not an empty string. This is because the Select value can be set to an empty string to clear the selection and show the placeholder."); return o.jsx(VD, { scope: r, value: a, disabled: l, textId: N, isSelected: h, onItemTextChange: w.useCallback(A => { x(E => E || (A?.textContent ?? "").trim()) }, []), children: o.jsx(Ad.ItemSlot, { scope: r, value: a, disabled: l, textValue: g, children: o.jsx(Ye.div, { role: "option", "aria-labelledby": N, "data-highlighted": y ? "" : void 0, "aria-selected": h && y, "data-state": h ? "checked" : "unchecked", "aria-disabled": l || void 0, "data-disabled": l ? "" : void 0, tabIndex: l ? void 0 : -1, ...d, ref: j, onFocus: ke(d.onFocus, () => b(!0)), onBlur: ke(d.onBlur, () => b(!1)), onClick: ke(d.onClick, () => { S.current !== "mouse" && _() }), onPointerUp: ke(d.onPointerUp, () => { S.current === "mouse" && _() }), onPointerDown: ke(d.onPointerDown, A => { S.current = A.pointerType }), onPointerMove: ke(d.onPointerMove, A => { S.current = A.pointerType, l ? m.onItemLeave?.() : S.current === "mouse" && A.currentTarget.focus({ preventScroll: !0 }) }), onPointerLeave: ke(d.onPointerLeave, A => { A.currentTarget === document.activeElement && m.onItemLeave?.() }), onKeyDown: ke(d.onKeyDown, A => { m.searchRef?.current !== "" && A.key === " " || (CD.includes(A.key) && _(), A.key === " " && A.preventDefault()) }) }) }) }) }); B2.displayName = Xu; var Ki = "SelectItemText", V2 = w.forwardRef((e, n) => { const { __scopeSelect: r, className: a, style: l, ...c } = e, d = Hr(Ki, r), f = Ur(Ki, r), m = U2(Ki, r), h = MD(Ki, r), [g, x] = w.useState(null), y = rt(n, _ => x(_), m.onItemTextChange, _ => f.itemTextRefCallback?.(_, m.value, m.disabled)), b = g?.textContent, j = w.useMemo(() => o.jsx("option", { value: m.value, disabled: m.disabled, children: b }, m.value), [m.disabled, m.value, b]), { onNativeOptionAdd: N, onNativeOptionRemove: S } = h; return Wt(() => (N(j), () => S(j)), [N, S, j]), o.jsxs(o.Fragment, { children: [o.jsx(Ye.span, { id: m.textId, ...c, ref: y }), m.isSelected && d.valueNode && !d.valueNodeHasChildren ? Nl.createPortal(c.children, d.valueNode) : null] }) }); V2.displayName = Ki; var q2 = "SelectItemIndicator", F2 = w.forwardRef((e, n) => { const { __scopeSelect: r, ...a } = e; return U2(q2, r).isSelected ? o.jsx(Ye.span, { "aria-hidden": !0, ...a, ref: n }) : null }); F2.displayName = q2; var ap = "SelectScrollUpButton", Y2 = w.forwardRef((e, n) => { const r = Ur(ap, e.__scopeSelect), a = yg(ap, e.__scopeSelect), [l, c] = w.useState(!1), d = rt(n, a.onScrollButtonChange); return Wt(() => { if (r.viewport && r.isPositioned) { let f = function () { const h = m.scrollTop > 0; c(h) }; const m = r.viewport; return f(), m.addEventListener("scroll", f), () => m.removeEventListener("scroll", f) } }, [r.viewport, r.isPositioned]), l ? o.jsx(X2, { ...e, ref: d, onAutoScroll: () => { const { viewport: f, selectedItem: m } = r; f && m && (f.scrollTop = f.scrollTop - m.offsetHeight) } }) : null }); Y2.displayName = ap; var ip = "SelectScrollDownButton", G2 = w.forwardRef((e, n) => { const r = Ur(ip, e.__scopeSelect), a = yg(ip, e.__scopeSelect), [l, c] = w.useState(!1), d = rt(n, a.onScrollButtonChange); return Wt(() => { if (r.viewport && r.isPositioned) { let f = function () { const h = m.scrollHeight - m.clientHeight, g = Math.ceil(m.scrollTop) < h; c(g) }; const m = r.viewport; return f(), m.addEventListener("scroll", f), () => m.removeEventListener("scroll", f) } }, [r.viewport, r.isPositioned]), l ? o.jsx(X2, { ...e, ref: d, onAutoScroll: () => { const { viewport: f, selectedItem: m } = r; f && m && (f.scrollTop = f.scrollTop + m.offsetHeight) } }) : null }); G2.displayName = ip; var X2 = w.forwardRef((e, n) => { const { __scopeSelect: r, onAutoScroll: a, ...l } = e, c = Ur("SelectScrollButton", r), d = w.useRef(null), f = Md(r), m = w.useCallback(() => { d.current !== null && (window.clearInterval(d.current), d.current = null) }, []); return w.useEffect(() => () => m(), [m]), Wt(() => { f().find(g => g.ref.current === document.activeElement)?.ref.current?.scrollIntoView({ block: "nearest" }) }, [f]), o.jsx(Ye.div, { "aria-hidden": !0, ...l, ref: n, style: { flexShrink: 0, ...l.style }, onPointerDown: ke(l.onPointerDown, () => { d.current === null && (d.current = window.setInterval(a, 50)) }), onPointerMove: ke(l.onPointerMove, () => { c.onItemLeave?.(), d.current === null && (d.current = window.setInterval(a, 50)) }), onPointerLeave: ke(l.onPointerLeave, () => { m() }) }) }), qD = "SelectSeparator", FD = w.forwardRef((e, n) => { const { __scopeSelect: r, ...a } = e; return o.jsx(Ye.div, { "aria-hidden": !0, ...a, ref: n }) }); FD.displayName = qD; var lp = "SelectArrow", YD = w.forwardRef((e, n) => { const { __scopeSelect: r, ...a } = e, l = Rd(r), c = Hr(lp, r), d = Ur(lp, r); return c.open && d.position === "popper" ? o.jsx(Vp, { ...l, ...a, ref: n }) : null }); YD.displayName = lp; var GD = "SelectBubbleInput", Z2 = w.forwardRef(({ __scopeSelect: e, value: n, ...r }, a) => { const l = w.useRef(null), c = rt(a, l), d = fg(n); return w.useEffect(() => { const f = l.current; if (!f) return; const m = window.HTMLSelectElement.prototype, g = Object.getOwnPropertyDescriptor(m, "value").set; if (d !== n && g) { const x = new Event("change", { bubbles: !0 }); g.call(f, n), f.dispatchEvent(x) } }, [d, n]), o.jsx(Ye.select, { ...r, style: { ...GN, ...r.style }, ref: c, defaultValue: n }) }); Z2.displayName = GD; function W2(e) { return e === "" || e === void 0 } function K2(e) { const n = Zt(e), r = w.useRef(""), a = w.useRef(0), l = w.useCallback(d => { const f = r.current + d; n(f), (function m(h) { r.current = h, window.clearTimeout(a.current), h !== "" && (a.current = window.setTimeout(() => m(""), 1e3)) })(f) }, [n]), c = w.useCallback(() => { r.current = "", window.clearTimeout(a.current) }, []); return w.useEffect(() => () => window.clearTimeout(a.current), []), [r, l, c] } function Q2(e, n, r) { const l = n.length > 1 && Array.from(n).every(h => h === n[0]) ? n[0] : n, c = r ? e.indexOf(r) : -1; let d = XD(e, Math.max(c, 0)); l.length === 1 && (d = d.filter(h => h !== r)); const m = d.find(h => h.textValue.toLowerCase().startsWith(l.toLowerCase())); return m !== r ? m : void 0 } function XD(e, n) { return e.map((r, a) => e[(n + a) % e.length]) } var ZD = C2, WD = T2, KD = M2, QD = R2, JD = D2, e6 = O2, t6 = $2, n6 = B2, s6 = V2, r6 = F2, o6 = Y2, a6 = G2; function vg({ ...e }) { return o.jsx(ZD, { "data-slot": "select", ...e }) } function bg({ ...e }) { return o.jsx(KD, { "data-slot": "select-value", ...e }) } function wg({ className: e, size: n = "default", children: r, ...a }) { return o.jsxs(WD, { "data-slot": "select-trigger", "data-size": n, className: We("border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", e), ...a, children: [r, o.jsx(QD, { asChild: !0, children: o.jsx(Rt, { className: "size-4 opacity-50" }) })] }) } function Ng({ className: e, children: n, position: r = "popper", ...a }) { return o.jsx(JD, { children: o.jsxs(e6, { "data-slot": "select-content", className: We("bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", r === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", e), position: r, ...a, children: [o.jsx(i6, {}), o.jsx(t6, { className: We("p-1", r === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"), children: n }), o.jsx(l6, {})] }) }) } function jg({ className: e, children: n, ...r }) { return o.jsxs(n6, { "data-slot": "select-item", className: We("focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", e), ...r, children: [o.jsx("span", { className: "absolute right-2 flex size-3.5 items-center justify-center", children: o.jsx(r6, { children: o.jsx(jo, { className: "size-4" }) }) }), o.jsx(s6, { children: n })] }) } function i6({ className: e, ...n }) { return o.jsx(o6, { "data-slot": "select-scroll-up-button", className: We("flex cursor-default items-center justify-center py-1", e), ...n, children: o.jsx(rN, { className: "size-4" }) }) } function l6({ className: e, ...n }) { return o.jsx(a6, { "data-slot": "select-scroll-down-button", className: We("flex cursor-default items-center justify-center py-1", e), ...n, children: o.jsx(Rt, { className: "size-4" }) }) } function io({ title: e, icon: n, children: r, className: a = "" }) { return o.jsxs("div", { className: `border rounded-lg p-4 bg-card ${a}`, children: [o.jsxs("div", { className: "flex items-center gap-2 mb-3", children: [n, o.jsx("h3", { className: "text-sm font-semibold text-foreground", children: e })] }), o.jsx("div", { className: "text-sm text-muted-foreground", children: r })] }) } function c6({ agent: e, open: n, onOpenChange: r }) { const a = e.source === "directory" ? o.jsx(aN, { className: "h-4 w-4 text-muted-foreground" }) : e.source === "in_memory" ? o.jsx(Kh, { className: "h-4 w-4 text-muted-foreground" }) : o.jsx(iN, { className: "h-4 w-4 text-muted-foreground" }), l = e.source === "directory" ? "Local" : e.source === "in_memory" ? "In-Memory" : "Gallery"; return o.jsx(Ir, { open: n, onOpenChange: r, children: o.jsxs(Lr, { className: "max-w-4xl max-h-[90vh] flex flex-col", children: [o.jsxs($r, { className: "px-6 pt-6 flex-shrink-0", children: [o.jsx(Pr, { children: "Agent Details" }), o.jsx(So, { onClose: () => r(!1) })] }), o.jsxs("div", { className: "px-6 pb-6 overflow-y-auto flex-1", children: [o.jsxs("div", { className: "mb-6", children: [o.jsxs("div", { className: "flex items-center gap-3 mb-2", children: [o.jsx(Vs, { className: "h-6 w-6 text-primary" }), o.jsx("h2", { className: "text-xl font-semibold text-foreground", children: e.name || e.id })] }), e.description && o.jsx("p", { className: "text-muted-foreground", children: e.description })] }), o.jsx("div", { className: "h-px bg-border mb-6" }), o.jsxs("div", { className: "grid grid-cols-1 md:grid-cols-2 gap-4 mb-4", children: [(e.model_id || e.chat_client_type) && o.jsx(io, { title: "Model & Client", icon: o.jsx(Vs, { className: "h-4 w-4 text-muted-foreground" }), children: o.jsxs("div", { className: "space-y-1", children: [e.model_id && o.jsx("div", { className: "font-mono text-foreground", children: e.model_id }), e.chat_client_type && o.jsxs("div", { className: "text-xs", children: ["(", e.chat_client_type, ")"] })] }) }), o.jsx(io, { title: "Source", icon: a, children: o.jsxs("div", { className: "space-y-1", children: [o.jsx("div", { className: "text-foreground", children: l }), e.module_path && o.jsx("div", { className: "font-mono text-xs break-all", children: e.module_path })] }) }), o.jsx(io, { title: "Environment", icon: e.has_env ? o.jsx(kl, { className: "h-4 w-4 text-orange-500" }) : o.jsx(yd, { className: "h-4 w-4 text-green-500" }), className: "md:col-span-2", children: o.jsx("div", { className: e.has_env ? "text-orange-600 dark:text-orange-400" : "text-green-600 dark:text-green-400", children: e.has_env ? "Requires environment variables" : "No environment variables required" }) })] }), e.instructions && o.jsx(io, { title: "Instructions", icon: o.jsx(qs, { className: "h-4 w-4 text-muted-foreground" }), className: "mb-4", children: o.jsx("div", { className: "text-sm text-foreground leading-relaxed whitespace-pre-wrap", children: e.instructions }) }), o.jsxs("div", { className: "grid grid-cols-1 md:grid-cols-2 gap-4", children: [e.tools && e.tools.length > 0 && o.jsx(io, { title: `Tools (${e.tools.length})`, icon: o.jsx(Uu, { className: "h-4 w-4 text-muted-foreground" }), children: o.jsx("ul", { className: "space-y-1", children: e.tools.map((c, d) => o.jsxs("li", { className: "font-mono text-xs text-foreground", children: ["• ", c] }, d)) }) }), e.middleware && e.middleware.length > 0 && o.jsx(io, { title: `MiddlewareTypes (${e.middleware.length})`, icon: o.jsx(Uu, { className: "h-4 w-4 text-muted-foreground" }), children: o.jsx("ul", { className: "space-y-1", children: e.middleware.map((c, d) => o.jsxs("li", { className: "font-mono text-xs text-foreground", children: ["• ", c] }, d)) }) }), e.context_providers && e.context_providers.length > 0 && o.jsx(io, { title: `Context Providers (${e.context_providers.length})`, icon: o.jsx(Kh, { className: "h-4 w-4 text-muted-foreground" }), className: !e.middleware || e.middleware.length === 0 ? "md:col-start-2" : "", children: o.jsx("ul", { className: "space-y-1", children: e.context_providers.map((c, d) => o.jsxs("li", { className: "font-mono text-xs text-foreground", children: ["• ", c] }, d)) }) })] })] })] }) }) } function u6({ item: e, toolCalls: n = [], toolResults: r = [] }) {
+ const [a, l] = w.useState(!1), [c, d] = w.useState(!1), [f, m] = w.useState(!1), h = le(y => y.showToolCalls), g = () => e.type === "message" ? e.content.filter(y => y.type === "text").map(y => y.text).join(`
`):"",x=async()=>{const y=g();if(y)try{await navigator.clipboard.writeText(y),d(!0),setTimeout(()=>d(!1),2e3)}catch(b){console.error("Failed to copy:",b)}};if(e.type==="message"){const y=e.role==="user",b=e.status==="incomplete",j=y?cN:b?hs:Vs,N=g();return o.jsxs("div",{className:`flex gap-3 ${y?"flex-row-reverse":""}`,onMouseEnter:()=>l(!0),onMouseLeave:()=>l(!1),children:[o.jsx("div",{className:`flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border ${y?"bg-primary text-primary-foreground":b?"bg-orange-100 dark:bg-orange-900 text-orange-600 dark:text-orange-400 border-orange-200 dark:border-orange-800":"bg-muted"}`,children:o.jsx(j,{className:"h-4 w-4"})}),o.jsxs("div",{className:`flex flex-col space-y-1 ${y?"items-end":"items-start"} max-w-[80%]`,children:[o.jsxs("div",{className:"relative group",children:[o.jsxs("div",{className:`rounded px-3 py-2 text-sm ${y?"bg-primary text-primary-foreground":b?"bg-orange-50 dark:bg-orange-950/50 text-orange-800 dark:text-orange-200 border border-orange-200 dark:border-orange-800":"bg-muted"}`,children:[b&&o.jsxs("div",{className:"flex items-start gap-2 mb-2",children:[o.jsx(hs,{className:"h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0"}),o.jsx("span",{className:"font-medium text-sm",children:"Unable to process request"})]}),o.jsx("div",{className:b?"text-xs leading-relaxed break-all":"",children:o.jsx(_D,{item:e})})]}),N&&a&&o.jsx("button",{onClick:x,className:`absolute top-1 right-1
p-1.5 rounded-md border shadow-sm
bg-background hover:bg-accent
@@ -578,7 +583,7 @@ asyncio.run(main())`})]})]}),o.jsxs("div",{className:"flex gap-2 pt-4 border-t",
0% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -10; }
}
-
+
/* Dark theme styles for React Flow controls */
.dark .react-flow__controls {
background-color: rgba(31, 41, 55, 0.9) !important;
diff --git a/python/packages/devui/frontend/src/components/features/agent/agent-details-modal.tsx b/python/packages/devui/frontend/src/components/features/agent/agent-details-modal.tsx
index f9fa4480a0..117e6e2e95 100644
--- a/python/packages/devui/frontend/src/components/features/agent/agent-details-modal.tsx
+++ b/python/packages/devui/frontend/src/components/features/agent/agent-details-modal.tsx
@@ -161,7 +161,7 @@ export function AgentDetailsModal({
)}
- {/* Tools and Middleware Grid */}
+ {/* Tools and MiddlewareTypes Grid */}
{/* Tools */}
{agent.tools && agent.tools.length > 0 && (
diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml
index 6ea79e48e0..2b5cbf9184 100644
--- a/python/packages/devui/pyproject.toml
+++ b/python/packages/devui/pyproject.toml
@@ -30,7 +30,7 @@ dependencies = [
]
[project.optional-dependencies]
-dev = ["pytest>=7.0.0", "watchdog>=3.0.0"]
+dev = ["pytest>=7.0.0", "watchdog>=3.0.0", "agent-framework-orchestrations"]
all = ["pytest>=7.0.0", "watchdog>=3.0.0"]
[project.scripts]
@@ -49,7 +49,7 @@ fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
-pythonpath = ["tests"]
+pythonpath = ["tests/devui"]
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
diff --git a/python/packages/devui/tests/capture_messages.py b/python/packages/devui/tests/devui/capture_messages.py
similarity index 100%
rename from python/packages/devui/tests/capture_messages.py
rename to python/packages/devui/tests/devui/capture_messages.py
diff --git a/python/packages/devui/tests/test_helpers.py b/python/packages/devui/tests/devui/conftest.py
similarity index 65%
rename from python/packages/devui/tests/test_helpers.py
rename to python/packages/devui/tests/devui/conftest.py
index 69b914a497..a9a1bcb971 100644
--- a/python/packages/devui/tests/test_helpers.py
+++ b/python/packages/devui/tests/devui/conftest.py
@@ -1,22 +1,21 @@
# Copyright (c) Microsoft. All rights reserved.
-"""Shared test utilities for DevUI tests.
+"""Pytest configuration and fixtures for DevUI tests.
-This module provides reusable test helpers including:
+This module provides reusable test fixtures including:
- Mock chat clients that don't require API keys
- Real workflow event classes from agent_framework
- Test agents and executors for workflow testing
- Factory functions for test data
-
-These follow the patterns established in other agent_framework packages
-(like a2a, ag-ui) which use explicit imports instead of conftest.py
-to avoid pytest plugin conflicts when running tests across packages.
"""
import sys
-from collections.abc import AsyncIterable, MutableSequence
+from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence
+from pathlib import Path
from typing import Any, Generic
+import pytest
+import pytest_asyncio
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
@@ -28,30 +27,29 @@
ChatResponse,
ChatResponseUpdate,
Content,
- use_chat_middleware,
+ ResponseStream,
)
from agent_framework._clients import TOptions_co
from agent_framework._workflows._agent_executor import AgentExecutorResponse
-from agent_framework.orchestrations import ConcurrentBuilder, SequentialBuilder
-
-if sys.version_info >= (3, 12):
- from typing import override # type: ignore # pragma: no cover
-else:
- from typing_extensions import override # type: ignore[import] # pragma: no cover
-
-# Import real workflow event classes - NOT mocks!
from agent_framework._workflows._events import (
ExecutorCompletedEvent,
ExecutorFailedEvent,
ExecutorInvokedEvent,
WorkflowErrorDetails,
)
+from agent_framework.orchestrations import ConcurrentBuilder, SequentialBuilder
from agent_framework_devui._discovery import EntityDiscovery
from agent_framework_devui._executor import AgentFrameworkExecutor
from agent_framework_devui._mapper import MessageMapper
from agent_framework_devui.models._openai_custom import AgentFrameworkRequest
+if sys.version_info >= (3, 12):
+ from typing import override # type: ignore # pragma: no cover
+else:
+ from typing_extensions import override # type: ignore[import] # pragma: no cover
+
+
# =============================================================================
# Mock Chat Clients (from core tests pattern)
# =============================================================================
@@ -92,7 +90,6 @@ async def get_streaming_response(
yield ChatResponseUpdate(contents=[Content.from_text(text="test streaming response")], role="assistant")
-@use_chat_middleware
class MockBaseChatClient(BaseChatClient[TOptions_co], Generic[TOptions_co]):
"""Full BaseChatClient mock with middleware support.
@@ -109,27 +106,27 @@ def __init__(self, **kwargs: Any):
self.received_messages: list[list[ChatMessage]] = []
@override
- async def _inner_get_response(
+ def _inner_get_response(
self,
*,
- messages: MutableSequence[ChatMessage],
- options: dict[str, Any],
+ messages: Sequence[ChatMessage],
+ stream: bool,
+ options: Mapping[str, Any],
**kwargs: Any,
- ) -> ChatResponse:
- self.call_count += 1
- self.received_messages.append(list(messages))
- if self.run_responses:
- return self.run_responses.pop(0)
- return ChatResponse(messages=ChatMessage("assistant", ["Mock response from ChatAgent"]))
+ ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
+ if stream:
+ return self._build_response_stream(self._stream_impl(messages))
- @override
- async def _inner_get_streaming_response(
- self,
- *,
- messages: MutableSequence[ChatMessage],
- options: dict[str, Any],
- **kwargs: Any,
- ) -> AsyncIterable[ChatResponseUpdate]:
+ async def _get() -> ChatResponse:
+ self.call_count += 1
+ self.received_messages.append(list(messages))
+ if self.run_responses:
+ return self.run_responses.pop(0)
+ return ChatResponse(messages=ChatMessage("assistant", ["Mock response from ChatAgent"]))
+
+ return _get()
+
+ async def _stream_impl(self, messages: Sequence[ChatMessage]) -> AsyncIterable[ChatResponseUpdate]:
self.call_count += 1
self.received_messages.append(list(messages))
if self.streaming_responses:
@@ -162,7 +159,20 @@ def __init__(
self.streaming_chunks = streaming_chunks or [response_text]
self.call_count = 0
- async def run(
+ def run(
+ self,
+ messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
+ *,
+ stream: bool = False,
+ thread: AgentThread | None = None,
+ **kwargs: Any,
+ ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
+ self.call_count += 1
+ if stream:
+ return self._run_stream(messages=messages, thread=thread, **kwargs)
+ return self._run(messages=messages, thread=thread, **kwargs)
+
+ async def _run(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
@@ -172,16 +182,20 @@ async def run(
self.call_count += 1
return AgentResponse(messages=[ChatMessage("assistant", [Content.from_text(text=self.response_text)])])
- async def run_stream(
+ def _run_stream(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
- ) -> AsyncIterable[AgentResponseUpdate]:
+ ) -> ResponseStream[AgentResponseUpdate, AgentResponse]:
self.call_count += 1
- for chunk in self.streaming_chunks:
- yield AgentResponseUpdate(contents=[Content.from_text(text=chunk)], role="assistant")
+
+ async def _iter():
+ for chunk in self.streaming_chunks:
+ yield AgentResponseUpdate(contents=[Content.from_text(text=chunk)], role="assistant")
+
+ return ResponseStream(_iter(), finalizer=AgentResponse.from_updates)
class MockToolCallingAgent(BaseAgent):
@@ -191,115 +205,87 @@ def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
self.call_count = 0
- async def run(
+ def run(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
+ stream: bool = False,
thread: AgentThread | None = None,
**kwargs: Any,
- ) -> AgentResponse:
+ ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
self.call_count += 1
+ if stream:
+ return self._run_stream(messages=messages, thread=thread, **kwargs)
+ return self._run(messages=messages, thread=thread, **kwargs)
+
+ async def _run(
+ self,
+ messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
+ *,
+ thread: AgentThread | None = None,
+ **kwargs: Any,
+ ) -> AgentResponse:
return AgentResponse(messages=[ChatMessage("assistant", ["done"])])
- async def run_stream(
+ def _run_stream(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
- ) -> AsyncIterable[AgentResponseUpdate]:
- self.call_count += 1
- # First: text
- yield AgentResponseUpdate(
- contents=[Content.from_text(text="Let me search for that...")],
- role="assistant",
- )
- # Second: tool call
- yield AgentResponseUpdate(
- contents=[
- Content.from_function_call(
- call_id="call_123",
- name="search",
- arguments={"query": "weather"},
- )
- ],
- role="assistant",
- )
- # Third: tool result
- yield AgentResponseUpdate(
- contents=[
- Content.from_function_result(
- call_id="call_123",
- result={"temperature": 72, "condition": "sunny"},
- )
- ],
- role="tool",
- )
- # Fourth: final text
- yield AgentResponseUpdate(
- contents=[Content.from_text(text="The weather is sunny, 72°F.")],
- role="assistant",
- )
+ ) -> ResponseStream[AgentResponseUpdate, AgentResponse]:
+ async def _iter() -> AsyncIterable[AgentResponseUpdate]:
+ # First: text
+ yield AgentResponseUpdate(
+ contents=[Content.from_text(text="Let me search for that...")],
+ role="assistant",
+ )
+ # Second: tool call
+ yield AgentResponseUpdate(
+ contents=[
+ Content.from_function_call(
+ call_id="call_123",
+ name="search",
+ arguments={"query": "weather"},
+ )
+ ],
+ role="assistant",
+ )
+ # Third: tool result
+ yield AgentResponseUpdate(
+ contents=[
+ Content.from_function_result(
+ call_id="call_123",
+ result={"temperature": 72, "condition": "sunny"},
+ )
+ ],
+ role="tool",
+ )
+ # Fourth: final text
+ yield AgentResponseUpdate(
+ contents=[Content.from_text(text="The weather is sunny, 72°F.")],
+ role="assistant",
+ )
+
+ return ResponseStream(_iter(), finalizer=AgentResponse.from_updates)
# =============================================================================
-# Factory Functions for Test Data
+# Helper Functions for Test Data Creation
# =============================================================================
-def create_mapper() -> MessageMapper:
- """Create a fresh MessageMapper."""
- return MessageMapper()
-
-
-def create_test_request(
- entity_id: str = "test_agent",
- input_text: str = "Test input",
- stream: bool = True,
-) -> AgentFrameworkRequest:
- """Create a standard test request."""
- return AgentFrameworkRequest(
- metadata={"entity_id": entity_id},
- input=input_text,
- stream=stream,
- )
-
-
-def create_mock_chat_client() -> MockChatClient:
- """Create a mock chat client."""
- return MockChatClient()
-
-
-def create_mock_base_chat_client() -> MockBaseChatClient:
- """Create a mock BaseChatClient."""
- return MockBaseChatClient()
-
-
-def create_mock_agent(
- id: str = "test_agent",
- name: str = "TestAgent",
- response_text: str = "Mock agent response",
-) -> MockAgent:
- """Create a mock agent."""
- return MockAgent(id=id, name=name, response_text=response_text)
-
-
-def create_mock_tool_agent(id: str = "tool_agent", name: str = "ToolAgent") -> MockToolCallingAgent:
- """Create a mock agent that simulates tool calls."""
- return MockToolCallingAgent(id=id, name=name)
-
-
-def create_agent_run_response(text: str = "Test response") -> AgentResponse:
+def _create_agent_run_response(text: str = "Test response") -> AgentResponse:
"""Create an AgentResponse with the given text."""
return AgentResponse(messages=[ChatMessage("assistant", [Content.from_text(text=text)])])
-def create_agent_executor_response(
+def _create_agent_executor_response(
executor_id: str = "test_executor",
response_text: str = "Executor response",
) -> AgentExecutorResponse:
"""Create an AgentExecutorResponse - the type that's nested in ExecutorCompletedEvent.data."""
- agent_response = create_agent_run_response(response_text)
+ agent_response = _create_agent_run_response(response_text)
return AgentExecutorResponse(
executor_id=executor_id,
agent_response=agent_response,
@@ -310,6 +296,21 @@ def create_agent_executor_response(
)
+# =============================================================================
+# Public Factory Functions (for direct import in tests)
+# =============================================================================
+
+
+def create_agent_run_response(text: str = "Test response") -> AgentResponse:
+ """Create an AgentResponse with the given text."""
+ return _create_agent_run_response(text)
+
+
+def create_executor_invoked_event(executor_id: str = "test_executor") -> ExecutorInvokedEvent:
+ """Create an ExecutorInvokedEvent."""
+ return ExecutorInvokedEvent(executor_id=executor_id)
+
+
def create_executor_completed_event(
executor_id: str = "test_executor",
with_agent_response: bool = True,
@@ -320,15 +321,10 @@ def create_executor_completed_event(
ExecutorCompletedEvent.data contains AgentExecutorResponse which contains
AgentResponse and ChatMessage objects (SerializationMixin, not Pydantic).
"""
- data = create_agent_executor_response(executor_id) if with_agent_response else {"simple": "dict"}
+ data = _create_agent_executor_response(executor_id) if with_agent_response else {"simple": "dict"}
return ExecutorCompletedEvent(executor_id=executor_id, data=data)
-def create_executor_invoked_event(executor_id: str = "test_executor") -> ExecutorInvokedEvent:
- """Create an ExecutorInvokedEvent."""
- return ExecutorInvokedEvent(executor_id=executor_id)
-
-
def create_executor_failed_event(
executor_id: str = "test_executor",
error_message: str = "Test error",
@@ -339,11 +335,97 @@ def create_executor_failed_event(
# =============================================================================
-# Workflow Setup Helpers (async factory functions)
+# Pytest Fixtures
+# =============================================================================
+
+
+@pytest.fixture
+def mapper() -> MessageMapper:
+ """Create a fresh MessageMapper for each test."""
+ return MessageMapper()
+
+
+@pytest.fixture
+def test_request() -> AgentFrameworkRequest:
+ """Create a standard test request."""
+ return AgentFrameworkRequest(
+ metadata={"entity_id": "test_agent"},
+ input="Test input",
+ stream=True,
+ )
+
+
+@pytest.fixture
+def mock_chat_client() -> MockChatClient:
+ """Create a mock chat client."""
+ return MockChatClient()
+
+
+@pytest.fixture
+def mock_base_chat_client() -> MockBaseChatClient:
+ """Create a mock BaseChatClient."""
+ return MockBaseChatClient()
+
+
+@pytest.fixture
+def mock_agent() -> MockAgent:
+ """Create a mock agent."""
+ return MockAgent(id="test_agent", name="TestAgent", response_text="Mock agent response")
+
+
+@pytest.fixture
+def mock_tool_agent() -> MockToolCallingAgent:
+ """Create a mock agent that simulates tool calls."""
+ return MockToolCallingAgent(id="tool_agent", name="ToolAgent")
+
+
+@pytest.fixture
+def agent_run_response() -> AgentResponse:
+ """Create an AgentResponse with default text."""
+ return _create_agent_run_response()
+
+
+@pytest.fixture
+def executor_completed_event() -> ExecutorCompletedEvent:
+ """Create an ExecutorCompletedEvent with realistic nested data.
+
+ This creates the exact data structure that caused the serialization bug:
+ ExecutorCompletedEvent.data contains AgentExecutorResponse which contains
+ AgentResponse and ChatMessage objects (SerializationMixin, not Pydantic).
+ """
+ data = _create_agent_executor_response("test_executor")
+ return ExecutorCompletedEvent(executor_id="test_executor", data=data)
+
+
+@pytest.fixture
+def executor_invoked_event() -> ExecutorInvokedEvent:
+ """Create an ExecutorInvokedEvent."""
+ return ExecutorInvokedEvent(executor_id="test_executor")
+
+
+@pytest.fixture
+def executor_failed_event() -> ExecutorFailedEvent:
+ """Create an ExecutorFailedEvent."""
+ details = WorkflowErrorDetails(error_type="TestError", message="Test error")
+ return ExecutorFailedEvent(executor_id="test_executor", details=details)
+
+
+@pytest.fixture
+def test_entities_dir() -> str:
+ """Use the samples directory which has proper entity structure."""
+ current_dir = Path(__file__).parent
+ # Navigate to python/samples/getting_started/devui
+ samples_dir = current_dir.parent.parent.parent.parent / "samples" / "getting_started" / "devui"
+ return str(samples_dir.resolve())
+
+
+# =============================================================================
+# Async Fixtures for Executor/Workflow Setup
# =============================================================================
-async def create_executor_with_real_agent() -> tuple[AgentFrameworkExecutor, str, MockBaseChatClient]:
+@pytest_asyncio.fixture
+async def executor_with_real_agent() -> tuple[AgentFrameworkExecutor, str, MockBaseChatClient]:
"""Create an executor with a REAL ChatAgent using mock chat client.
This tests the full execution pipeline:
@@ -375,7 +457,8 @@ async def create_executor_with_real_agent() -> tuple[AgentFrameworkExecutor, str
return executor, entity_info.id, mock_client
-async def create_sequential_workflow() -> tuple[AgentFrameworkExecutor, str, MockBaseChatClient, Any]:
+@pytest_asyncio.fixture
+async def sequential_workflow() -> tuple[AgentFrameworkExecutor, str, MockBaseChatClient, Any]:
"""Create a realistic sequential workflow (Writer -> Reviewer).
This provides a reusable multi-agent workflow that:
@@ -418,7 +501,8 @@ async def create_sequential_workflow() -> tuple[AgentFrameworkExecutor, str, Moc
return executor, entity_info.id, mock_client, workflow
-async def create_concurrent_workflow() -> tuple[AgentFrameworkExecutor, str, MockBaseChatClient, Any]:
+@pytest_asyncio.fixture
+async def concurrent_workflow() -> tuple[AgentFrameworkExecutor, str, MockBaseChatClient, Any]:
"""Create a realistic concurrent workflow (Researcher | Analyst | Summarizer).
This provides a reusable fan-out/fan-in workflow that:
diff --git a/python/packages/devui/tests/test_checkpoints.py b/python/packages/devui/tests/devui/test_checkpoints.py
similarity index 99%
rename from python/packages/devui/tests/test_checkpoints.py
rename to python/packages/devui/tests/devui/test_checkpoints.py
index 3e1e0c96c7..e1a3114f14 100644
--- a/python/packages/devui/tests/test_checkpoints.py
+++ b/python/packages/devui/tests/devui/test_checkpoints.py
@@ -338,7 +338,7 @@ async def test_manual_checkpoint_save_via_injected_storage(self, checkpoint_mana
checkpoint_storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
# Set build-time storage (equivalent to .with_checkpointing() at build time)
- # Note: In production, DevUI uses runtime injection via run_stream() parameter
+ # Note: In production, DevUI uses runtime injection via run(stream=True) parameter
if hasattr(test_workflow, "_runner") and hasattr(test_workflow._runner, "context"):
test_workflow._runner.context._checkpoint_storage = checkpoint_storage
@@ -406,7 +406,7 @@ async def test_workflow_auto_saves_checkpoints_to_injected_storage(self, checkpo
3. Framework automatically saves checkpoint to our storage
4. Checkpoint is accessible via manager for UI to list/resume
- Note: In production, DevUI passes checkpoint_storage to run_stream() as runtime parameter.
+ Note: In production, DevUI passes checkpoint_storage to run(stream=True) as runtime parameter.
This test uses build-time injection to verify framework's checkpoint auto-save behavior.
"""
entity_id = "test_entity"
@@ -427,7 +427,7 @@ async def test_workflow_auto_saves_checkpoints_to_injected_storage(self, checkpo
# Run workflow until it reaches IDLE_WITH_PENDING_REQUESTS (after checkpoint is created)
saw_request_event = False
- async for event in test_workflow.run_stream(WorkflowTestData(value="test")):
+ async for event in test_workflow.run(WorkflowTestData(value="test"), stream=True):
if isinstance(event, RequestInfoEvent):
saw_request_event = True
# Wait for IDLE_WITH_PENDING_REQUESTS status (comes after checkpoint creation)
diff --git a/python/packages/devui/tests/test_cleanup_hooks.py b/python/packages/devui/tests/devui/test_cleanup_hooks.py
similarity index 91%
rename from python/packages/devui/tests/test_cleanup_hooks.py
rename to python/packages/devui/tests/devui/test_cleanup_hooks.py
index 68c8ff6af2..f8bdf5c867 100644
--- a/python/packages/devui/tests/test_cleanup_hooks.py
+++ b/python/packages/devui/tests/devui/test_cleanup_hooks.py
@@ -33,10 +33,18 @@ def __init__(self, name: str = "TestAgent"):
self.cleanup_called = False
self.async_cleanup_called = False
- async def run_stream(self, messages=None, *, thread=None, **kwargs):
- """Mock streaming run method."""
- yield AgentResponse(
- messages=[ChatMessage("assistant", [Content.from_text(text="Test response")])],
+ async def run(self, messages=None, *, stream: bool = False, thread=None, **kwargs):
+ """Mock run method with streaming support."""
+ if stream:
+
+ async def _stream():
+ yield AgentResponse(
+ messages=[ChatMessage(role="assistant", contents=[Content.from_text(text="Test response")])],
+ )
+
+ return _stream()
+ return AgentResponse(
+ messages=[ChatMessage(role="assistant", contents=[Content.from_text(text="Test response")])],
)
@@ -277,9 +285,16 @@ class TestAgent:
name = "Test Agent"
description = "Test agent with cleanup"
- async def run_stream(self, messages=None, *, thread=None, **kwargs):
- yield AgentResponse(
- messages=[ChatMessage("assistant", [Content.from_text(text="Test")])],
+ async def run(self, messages=None, *, stream: bool = False, thread=None, **kwargs):
+ if stream:
+ async def _stream():
+ yield AgentResponse(
+ messages=[ChatMessage(role="assistant", content=[Content.from_text(text="Test")])],
+ inner_messages=[],
+ )
+ return _stream()
+ return AgentResponse(
+ messages=[ChatMessage(role="assistant", content=[Content.from_text(text="Test")])],
inner_messages=[],
)
diff --git a/python/packages/devui/tests/test_conversations.py b/python/packages/devui/tests/devui/test_conversations.py
similarity index 98%
rename from python/packages/devui/tests/test_conversations.py
rename to python/packages/devui/tests/devui/test_conversations.py
index cd1451f79b..dbc2e4ddb2 100644
--- a/python/packages/devui/tests/test_conversations.py
+++ b/python/packages/devui/tests/devui/test_conversations.py
@@ -216,7 +216,7 @@ async def test_list_items_converts_function_calls():
# Simulate messages from agent execution with function calls
messages = [
- ChatMessage("user", [{"type": "text", "text": "What's the weather in SF?"}]),
+ ChatMessage(role="user", contents=[{"type": "text", "text": "What's the weather in SF?"}]),
ChatMessage(
role="assistant",
contents=[
@@ -238,7 +238,7 @@ async def test_list_items_converts_function_calls():
}
],
),
- ChatMessage("assistant", [{"type": "text", "text": "The weather is sunny, 65°F"}]),
+ ChatMessage(role="assistant", contents=[{"type": "text", "text": "The weather is sunny, 65°F"}]),
]
# Add messages to thread
diff --git a/python/packages/devui/tests/test_discovery.py b/python/packages/devui/tests/devui/test_discovery.py
similarity index 94%
rename from python/packages/devui/tests/test_discovery.py
rename to python/packages/devui/tests/devui/test_discovery.py
index 8b0cf9fb3a..ac88f3bf3d 100644
--- a/python/packages/devui/tests/test_discovery.py
+++ b/python/packages/devui/tests/devui/test_discovery.py
@@ -6,19 +6,9 @@
import tempfile
from pathlib import Path
-import pytest
-
from agent_framework_devui._discovery import EntityDiscovery
-
-@pytest.fixture
-def test_entities_dir():
- """Use the samples directory which has proper entity structure."""
- # Get the samples directory from the main python samples folder
- current_dir = Path(__file__).parent
- # Navigate to python/samples/getting_started/devui
- samples_dir = current_dir.parent.parent.parent / "samples" / "getting_started" / "devui"
- return str(samples_dir.resolve())
+# Note: test_entities_dir fixture is provided by conftest.py
async def test_discover_agents(test_entities_dir):
@@ -89,7 +79,7 @@ async def test_discovery_accepts_agents_with_only_run():
class NonStreamingAgent:
id = "non_streaming"
name = "Non-Streaming Agent"
- description = "Agent without run_stream"
+ description = "Agent with run() method"
async def run(self, messages=None, *, thread=None, **kwargs):
return AgentResponse(
@@ -125,7 +115,6 @@ def get_new_thread(self, **kwargs):
enriched = discovery.get_entity_info(entity.id)
assert enriched.type == "agent" # Now correctly identified
assert enriched.name == "Non-Streaming Agent"
- assert not enriched.metadata.get("has_run_stream")
async def test_lazy_loading():
@@ -210,7 +199,7 @@ class TestAgent:
async def run(self, messages=None, *, thread=None, **kwargs):
return AgentResponse(
- messages=[ChatMessage("assistant", [Content.from_text(text="test")])],
+ messages=[ChatMessage(role="assistant", contents=[Content.from_text(text="test")])],
response_id="test"
)
@@ -342,7 +331,7 @@ class WeatherAgent:
name = "Weather Agent"
description = "Gets weather information"
- def run_stream(self, input_str):
+ def run(self, input_str, *, stream: bool = False, thread=None, **kwargs):
return f"Weather in {input_str}"
""")
diff --git a/python/packages/devui/tests/test_execution.py b/python/packages/devui/tests/devui/test_execution.py
similarity index 91%
rename from python/packages/devui/tests/test_execution.py
rename to python/packages/devui/tests/devui/test_execution.py
index ce763d227e..12ee7d8a7a 100644
--- a/python/packages/devui/tests/test_execution.py
+++ b/python/packages/devui/tests/devui/test_execution.py
@@ -15,16 +15,10 @@
from typing import Any
import pytest
-import pytest_asyncio
from agent_framework import AgentExecutor, ChatAgent, FunctionExecutor, WorkflowBuilder
-# Import test utilities
-from test_helpers import (
- MockBaseChatClient,
- create_concurrent_workflow,
- create_executor_with_real_agent,
- create_sequential_workflow,
-)
+# Import mock classes from conftest for direct use in some tests
+from conftest import MockBaseChatClient
from agent_framework_devui._discovery import EntityDiscovery
from agent_framework_devui._executor import AgentFrameworkExecutor, EntityNotFoundError
@@ -32,38 +26,10 @@
from agent_framework_devui.models._openai_custom import AgentFrameworkRequest
# =============================================================================
-# Local Fixtures (async factory-based)
+# Local Fixtures (module-specific)
# =============================================================================
-@pytest_asyncio.fixture
-async def executor_with_real_agent():
- """Create an executor with a REAL ChatAgent using mock chat client."""
- return await create_executor_with_real_agent()
-
-
-@pytest_asyncio.fixture
-async def sequential_workflow_fixture():
- """Create a realistic sequential workflow (Writer -> Reviewer)."""
- return await create_sequential_workflow()
-
-
-@pytest_asyncio.fixture
-async def concurrent_workflow_fixture():
- """Create a realistic concurrent workflow (Researcher | Analyst | Summarizer)."""
- return await create_concurrent_workflow()
-
-
-@pytest.fixture
-def test_entities_dir():
- """Use the samples directory which has proper entity structure."""
- # Get the samples directory from the main python samples folder
- current_dir = Path(__file__).parent
- # Navigate to python/samples/getting_started/devui
- samples_dir = current_dir.parent.parent.parent / "samples" / "getting_started" / "devui"
- return str(samples_dir.resolve())
-
-
@pytest.fixture
async def executor(test_entities_dir):
"""Create configured executor."""
@@ -419,9 +385,9 @@ async def test_request_extracts_entity_id_from_metadata(executor):
@pytest.mark.asyncio
-async def test_executor_get_start_executor_message_types(sequential_workflow_fixture):
+async def test_executor_get_start_executor_message_types(sequential_workflow):
"""Test _get_start_executor_message_types with real workflow."""
- executor, _entity_id, _mock_client, workflow = sequential_workflow_fixture
+ executor, _entity_id, _mock_client, workflow = sequential_workflow
start_exec, message_types = executor._get_start_executor_message_types(workflow)
@@ -493,11 +459,11 @@ async def process(self, text: str, ctx: WorkflowContext[Any, Any]) -> None:
@pytest.mark.asyncio
-async def test_executor_parse_converts_to_chat_message_for_sequential_workflow(sequential_workflow_fixture):
+async def test_executor_parse_converts_to_chat_message_for_sequential_workflow(sequential_workflow):
"""Sequential workflows convert string input to ChatMessage."""
from agent_framework import ChatMessage
- executor, _entity_id, _mock_client, workflow = sequential_workflow_fixture
+ executor, _entity_id, _mock_client, workflow = sequential_workflow
# Sequential workflows expect ChatMessage, so raw string becomes ChatMessage
parsed = executor._parse_raw_workflow_input(workflow, "hello")
@@ -564,23 +530,36 @@ def test_extract_workflow_hil_responses_handles_stringified_json():
assert executor._extract_workflow_hil_responses({"email": "test"}) is None
-async def test_executor_handles_non_streaming_agent():
- """Test executor can handle agents with only run() method (no run_stream)."""
- from agent_framework import AgentResponse, AgentThread, ChatMessage, Content
+async def test_executor_handles_streaming_agent():
+ """Test executor handles agents with run(stream=True) method."""
+ from agent_framework import AgentResponse, AgentResponseUpdate, AgentThread, ChatMessage, Content
- class NonStreamingAgent:
- """Agent with only run() method - does NOT satisfy full AgentProtocol."""
+ class StreamingAgent:
+ """Agent with run() method supporting stream parameter."""
- id = "non_streaming_test"
- name = "Non-Streaming Test Agent"
- description = "Test agent without run_stream()"
+ id = "streaming_test"
+ name = "Streaming Test Agent"
+ description = "Test agent with run(stream=True)"
- async def run(self, messages=None, *, thread=None, **kwargs):
+ def run(self, messages=None, *, stream=False, thread=None, **kwargs):
+ if stream:
+ # Return an async generator for streaming
+ return self._stream_impl(messages)
+ # Return awaitable for non-streaming
+ return self._run_impl(messages)
+
+ async def _run_impl(self, messages):
return AgentResponse(
- messages=[ChatMessage("assistant", [Content.from_text(text=f"Processed: {messages}")])],
+ messages=[ChatMessage(role="assistant", contents=[Content.from_text(text=f"Processed: {messages}")])],
response_id="test_123",
)
+ async def _stream_impl(self, messages):
+ yield AgentResponseUpdate(
+ contents=[Content.from_text(text=f"Processed: {messages}")],
+ role="assistant",
+ )
+
def get_new_thread(self, **kwargs):
return AgentThread()
@@ -589,11 +568,11 @@ def get_new_thread(self, **kwargs):
mapper = MessageMapper()
executor = AgentFrameworkExecutor(discovery, mapper)
- agent = NonStreamingAgent()
+ agent = StreamingAgent()
entity_info = await discovery.create_entity_info_from_object(agent, source="test")
discovery.register_entity(entity_info.id, entity_info, agent)
- # Execute non-streaming agent (use metadata.entity_id for routing)
+ # Execute streaming agent (use metadata.entity_id for routing)
request = AgentFrameworkRequest(
metadata={"entity_id": entity_info.id},
input="hello",
@@ -604,7 +583,7 @@ def get_new_thread(self, **kwargs):
async for event in executor.execute_streaming(request):
events.append(event)
- # Should get events even though agent doesn't stream
+ # Should get events from streaming agent
assert len(events) > 0
text_events = [e for e in events if hasattr(e, "type") and e.type == "response.output_text.delta"]
assert len(text_events) > 0
@@ -617,13 +596,13 @@ def get_new_thread(self, **kwargs):
@pytest.mark.asyncio
-async def test_full_pipeline_sequential_workflow(sequential_workflow_fixture):
+async def test_full_pipeline_sequential_workflow(sequential_workflow):
"""Test SequentialBuilder workflow full pipeline with JSON serialization.
- Uses the shared sequential_workflow_fixture (Writer → Reviewer) from conftest.
+ Uses the shared sequential_workflow fixture (Writer → Reviewer) from conftest.
Tests that all events can be JSON serialized for SSE streaming.
"""
- executor, entity_id, mock_client, _workflow = sequential_workflow_fixture
+ executor, entity_id, mock_client, _workflow = sequential_workflow
request = AgentFrameworkRequest(
metadata={"entity_id": entity_id},
@@ -652,13 +631,13 @@ async def test_full_pipeline_sequential_workflow(sequential_workflow_fixture):
@pytest.mark.asyncio
-async def test_full_pipeline_concurrent_workflow(concurrent_workflow_fixture):
+async def test_full_pipeline_concurrent_workflow(concurrent_workflow):
"""Test ConcurrentBuilder workflow full pipeline with JSON serialization.
- Uses the shared concurrent_workflow_fixture (Researcher | Analyst | Summarizer) from conftest.
+ Uses the shared concurrent_workflow fixture (Researcher | Analyst | Summarizer) from conftest.
Tests fan-out/fan-in pattern with parallel agent execution.
"""
- executor, entity_id, mock_client, _workflow = concurrent_workflow_fixture
+ executor, entity_id, mock_client, _workflow = concurrent_workflow
request = AgentFrameworkRequest(
metadata={"entity_id": entity_id},
@@ -769,9 +748,13 @@ class StreamingAgent:
name = "Streaming Test Agent"
description = "Test agent for streaming"
- async def run_stream(self, input_str):
- for i, word in enumerate(f"Processing {input_str}".split()):
- yield f"word_{i}: {word} "
+ async def run(self, input_str, *, stream: bool = False, thread=None, **kwargs):
+ if stream:
+ async def _stream():
+ for i, word in enumerate(f"Processing {input_str}".split()):
+ yield f"word_{i}: {word} "
+ return _stream()
+ return f"Processing {input_str}"
""")
discovery = EntityDiscovery(str(temp_path))
diff --git a/python/packages/devui/tests/test_mapper.py b/python/packages/devui/tests/devui/test_mapper.py
similarity index 97%
rename from python/packages/devui/tests/test_mapper.py
rename to python/packages/devui/tests/devui/test_mapper.py
index faae9b0673..3d3cf2194c 100644
--- a/python/packages/devui/tests/test_mapper.py
+++ b/python/packages/devui/tests/devui/test_mapper.py
@@ -24,14 +24,12 @@
WorkflowStatusEvent,
)
-# Import test utilities
-from test_helpers import (
+# Import factory functions from conftest for parameterized test data creation
+from conftest import (
create_agent_run_response,
create_executor_completed_event,
create_executor_failed_event,
create_executor_invoked_event,
- create_mapper,
- create_test_request,
)
from agent_framework_devui._mapper import MessageMapper
@@ -42,21 +40,7 @@
AgentStartedEvent,
)
-# =============================================================================
-# Local Fixtures (to replace conftest.py fixtures)
-# =============================================================================
-
-
-@pytest.fixture
-def mapper() -> MessageMapper:
- """Create a fresh MessageMapper for each test."""
- return create_mapper()
-
-
-@pytest.fixture
-def test_request() -> AgentFrameworkRequest:
- """Create a standard test request."""
- return create_test_request()
+# Note: mapper and test_request fixtures are provided by conftest.py
# =============================================================================
@@ -602,8 +586,8 @@ async def test_workflow_output_event_with_list_data(mapper: MessageMapper, test_
# Sequential/Concurrent workflows often output list[ChatMessage]
messages = [
- ChatMessage("user", [Content.from_text(text="Hello")]),
- ChatMessage("assistant", [Content.from_text(text="World")]),
+ ChatMessage(role="user", contents=[Content.from_text(text="Hello")]),
+ ChatMessage(role="assistant", contents=[Content.from_text(text="World")]),
]
event = WorkflowOutputEvent(data=messages, executor_id="complete")
events = await mapper.convert_event(event, test_request)
diff --git a/python/packages/devui/tests/test_multimodal_workflow.py b/python/packages/devui/tests/devui/test_multimodal_workflow.py
similarity index 93%
rename from python/packages/devui/tests/test_multimodal_workflow.py
rename to python/packages/devui/tests/devui/test_multimodal_workflow.py
index dbd4c4dfae..1124c9afce 100644
--- a/python/packages/devui/tests/test_multimodal_workflow.py
+++ b/python/packages/devui/tests/devui/test_multimodal_workflow.py
@@ -86,9 +86,8 @@ def test_convert_openai_input_to_chat_message_with_image(self):
assert result.contents[1].media_type == "image/png"
assert result.contents[1].uri == TEST_IMAGE_DATA_URI
- def test_parse_workflow_input_handles_json_string_with_multimodal(self):
+ async def test_parse_workflow_input_handles_json_string_with_multimodal(self):
"""Test that _parse_workflow_input correctly handles JSON string with multimodal content."""
- import asyncio
from agent_framework import ChatMessage
@@ -113,7 +112,7 @@ def test_parse_workflow_input_handles_json_string_with_multimodal(self):
mock_workflow = MagicMock()
# Parse the input
- result = asyncio.run(executor._parse_workflow_input(mock_workflow, json_string_input))
+ result = await executor._parse_workflow_input(mock_workflow, json_string_input)
# Verify result is ChatMessage with multimodal content
assert isinstance(result, ChatMessage), f"Expected ChatMessage, got {type(result)}"
@@ -127,9 +126,8 @@ def test_parse_workflow_input_handles_json_string_with_multimodal(self):
assert result.contents[1].type == "data"
assert result.contents[1].media_type == "image/png"
- def test_parse_workflow_input_still_handles_simple_dict(self):
+ async def test_parse_workflow_input_still_handles_simple_dict(self):
"""Test that simple dict input still works (backward compatibility)."""
- import asyncio
from agent_framework import ChatMessage
@@ -148,7 +146,7 @@ def test_parse_workflow_input_still_handles_simple_dict(self):
mock_workflow.get_start_executor.return_value = mock_executor
# Parse the input
- result = asyncio.run(executor._parse_workflow_input(mock_workflow, json_string_input))
+ result = await executor._parse_workflow_input(mock_workflow, json_string_input)
# Result should be ChatMessage (from _parse_structured_workflow_input)
assert isinstance(result, ChatMessage), f"Expected ChatMessage, got {type(result)}"
diff --git a/python/packages/devui/tests/test_openai_sdk_integration.py b/python/packages/devui/tests/devui/test_openai_sdk_integration.py
similarity index 100%
rename from python/packages/devui/tests/test_openai_sdk_integration.py
rename to python/packages/devui/tests/devui/test_openai_sdk_integration.py
diff --git a/python/packages/devui/tests/test_schema_generation.py b/python/packages/devui/tests/devui/test_schema_generation.py
similarity index 100%
rename from python/packages/devui/tests/test_schema_generation.py
rename to python/packages/devui/tests/devui/test_schema_generation.py
diff --git a/python/packages/devui/tests/test_server.py b/python/packages/devui/tests/devui/test_server.py
similarity index 96%
rename from python/packages/devui/tests/test_server.py
rename to python/packages/devui/tests/devui/test_server.py
index 16766bc14f..1489142914 100644
--- a/python/packages/devui/tests/test_server.py
+++ b/python/packages/devui/tests/devui/test_server.py
@@ -23,14 +23,7 @@ def __init__(self, *, input_types=None, handlers=None):
self._handlers = dict(handlers)
-@pytest.fixture
-def test_entities_dir():
- """Use the samples directory which has proper entity structure."""
- # Get the samples directory from the main python samples folder
- current_dir = Path(__file__).parent
- # Navigate to python/samples/getting_started/devui
- samples_dir = current_dir.parent.parent.parent / "samples" / "getting_started" / "devui"
- return str(samples_dir.resolve())
+# Note: test_entities_dir fixture is provided by conftest.py
async def test_server_health_endpoint(test_entities_dir):
@@ -159,6 +152,7 @@ async def test_credential_cleanup() -> None:
mock_client = Mock()
mock_client.async_credential = mock_credential
mock_client.model_id = "test-model"
+ mock_client.function_invocation_configuration = None
# Create agent with mock client
agent = ChatAgent(name="TestAgent", chat_client=mock_client, instructions="Test agent")
@@ -191,6 +185,7 @@ async def test_credential_cleanup_error_handling() -> None:
mock_client = Mock()
mock_client.async_credential = mock_credential
mock_client.model_id = "test-model"
+ mock_client.function_invocation_configuration = None
# Create agent with mock client
agent = ChatAgent(name="TestAgent", chat_client=mock_client, instructions="Test agent")
@@ -225,6 +220,7 @@ async def test_multiple_credential_attributes() -> None:
mock_client.credential = mock_cred1
mock_client.async_credential = mock_cred2
mock_client.model_id = "test-model"
+ mock_client.function_invocation_configuration = None
# Create agent with mock client
agent = ChatAgent(name="TestAgent", chat_client=mock_client, instructions="Test agent")
@@ -346,7 +342,7 @@ class WeatherAgent:
name = "Weather Agent"
description = "Gets weather information"
- def run_stream(self, input_str):
+ def run(self, input_str, *, stream: bool = False, thread=None, **kwargs):
return f"Weather in {input_str} is sunny"
""")
diff --git a/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py b/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py
index aabfa4bf08..c6e6eaad08 100644
--- a/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py
+++ b/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py
@@ -817,7 +817,7 @@ def from_chat_message(chat_message: ChatMessage) -> DurableAgentStateMessage:
]
return DurableAgentStateMessage(
- role=chat_message.role,
+ role=chat_message.role if hasattr(chat_message.role, "value") else str(chat_message.role),
contents=contents_list,
author_name=chat_message.author_name,
extension_data=dict(chat_message.additional_properties) if chat_message.additional_properties else None,
diff --git a/python/packages/durabletask/agent_framework_durabletask/_entities.py b/python/packages/durabletask/agent_framework_durabletask/_entities.py
index c842d58fe7..759d54065d 100644
--- a/python/packages/durabletask/agent_framework_durabletask/_entities.py
+++ b/python/packages/durabletask/agent_framework_durabletask/_entities.py
@@ -5,7 +5,7 @@
from __future__ import annotations
import inspect
-from collections.abc import AsyncIterable
+from datetime import datetime, timezone
from typing import Any, cast
from agent_framework import (
@@ -14,6 +14,7 @@
AgentResponseUpdate,
ChatMessage,
Content,
+ ResponseStream,
get_logger,
)
from durabletask.entities import DurableEntity
@@ -177,7 +178,10 @@ async def run(
error_message = ChatMessage(
role="assistant", contents=[Content.from_error(message=str(exc), error_code=type(exc).__name__)]
)
- error_response = AgentResponse(messages=[error_message])
+ error_response = AgentResponse(
+ messages=[error_message],
+ created_at=datetime.now(tz=timezone.utc).isoformat(),
+ )
error_state_response = DurableAgentStateResponse.from_run_response(correlation_id, error_response)
error_state_response.is_error = True
@@ -202,40 +206,47 @@ async def _invoke_agent(
request_message=request_message,
)
- run_stream_callable = getattr(self.agent, "run_stream", None)
- if callable(run_stream_callable):
- try:
- stream_candidate = run_stream_callable(**run_kwargs)
- if inspect.isawaitable(stream_candidate):
- stream_candidate = await stream_candidate
-
- return await self._consume_stream(
- stream=cast(AsyncIterable[AgentResponseUpdate], stream_candidate),
- callback_context=callback_context,
- )
- except TypeError as type_error:
- if "__aiter__" not in str(type_error):
- raise
- logger.debug(
- "run_stream returned a non-async result; falling back to run(): %s",
- type_error,
- )
- except Exception as stream_error:
- logger.warning(
- "run_stream failed; falling back to run(): %s",
- stream_error,
- exc_info=True,
- )
- else:
- logger.debug("Agent does not expose run_stream; falling back to run().")
+ run_callable = getattr(self.agent, "run", None)
+ if run_callable is None or not callable(run_callable):
+ raise AttributeError("Agent does not implement run() method")
+
+ # Try streaming first with run(stream=True)
+ try:
+ stream_candidate = run_callable(stream=True, **run_kwargs)
+ if inspect.isawaitable(stream_candidate):
+ stream_candidate = await stream_candidate
+
+ return await self._consume_stream(
+ stream=stream_candidate, # type: ignore[arg-type]
+ callback_context=callback_context,
+ )
+ except TypeError as type_error:
+ if "__aiter__" not in str(type_error) and "stream" not in str(type_error):
+ raise
+ logger.debug(
+ "run(stream=True) returned a non-async result; falling back to run(): %s",
+ type_error,
+ )
+ except Exception as stream_error:
+ logger.warning(
+ "run(stream=True) failed; falling back to run(): %s",
+ stream_error,
+ exc_info=True,
+ )
+ agent_run_response = run_callable(**run_kwargs)
+ if inspect.isawaitable(agent_run_response):
+ agent_run_response = await agent_run_response
- agent_run_response = await self._invoke_non_stream(run_kwargs)
+ if not isinstance(agent_run_response, AgentResponse):
+ raise TypeError(
+ f"Agent run() must return an AgentResponse instance; received {type(agent_run_response).__name__}"
+ )
await self._notify_final_response(agent_run_response, callback_context)
return agent_run_response
async def _consume_stream(
self,
- stream: AsyncIterable[AgentResponseUpdate],
+ stream: ResponseStream[AgentResponseUpdate, AgentResponse],
callback_context: AgentCallbackContext | None = None,
) -> AgentResponse:
"""Consume streaming responses and build the final AgentResponse."""
@@ -245,30 +256,11 @@ async def _consume_stream(
updates.append(update)
await self._notify_stream_update(update, callback_context)
- if updates:
- response = AgentResponse.from_updates(updates)
- else:
- logger.debug("[AgentEntity] No streaming updates received; creating empty response")
- response = AgentResponse(messages=[])
+ response = await stream.get_final_response()
await self._notify_final_response(response, callback_context)
return response
- async def _invoke_non_stream(self, run_kwargs: dict[str, Any]) -> AgentResponse:
- """Invoke the agent without streaming support."""
- run_callable = getattr(self.agent, "run", None)
- if run_callable is None or not callable(run_callable):
- raise AttributeError("Agent does not implement run() method")
-
- result = run_callable(**run_kwargs)
- if inspect.isawaitable(result):
- result = await result
-
- if not isinstance(result, AgentResponse):
- raise TypeError(f"Agent run() must return an AgentResponse instance; received {type(result).__name__}")
-
- return result
-
async def _notify_stream_update(
self,
update: AgentResponseUpdate,
diff --git a/python/packages/durabletask/agent_framework_durabletask/_shim.py b/python/packages/durabletask/agent_framework_durabletask/_shim.py
index a624cdc8b5..3291b8bfdc 100644
--- a/python/packages/durabletask/agent_framework_durabletask/_shim.py
+++ b/python/packages/durabletask/agent_framework_durabletask/_shim.py
@@ -10,10 +10,9 @@
from __future__ import annotations
from abc import ABC, abstractmethod
-from collections.abc import AsyncIterator
-from typing import Any, Generic, TypeVar
+from typing import Any, Generic, Literal, TypeVar
-from agent_framework import AgentProtocol, AgentResponseUpdate, AgentThread, ChatMessage
+from agent_framework import AgentProtocol, AgentThread, ChatMessage
from ._executors import DurableAgentExecutor
from ._models import DurableAgentThread
@@ -89,6 +88,7 @@ def run( # type: ignore[override]
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
+ stream: Literal[False] = False,
thread: AgentThread | None = None,
options: dict[str, Any] | None = None,
) -> TaskT:
@@ -96,6 +96,8 @@ def run( # type: ignore[override]
Args:
messages: The message(s) to send to the agent
+ stream: Whether to use streaming for the response (must be False)
+ DurableAgents do not support streaming mode.
thread: Optional agent thread for conversation context
options: Optional options dictionary. Supported keys include
``response_format``, ``enable_tool_calls``, and ``wait_for_response``.
@@ -115,6 +117,8 @@ def run( # type: ignore[override]
Raises:
ValueError: If wait_for_response=False is used in an unsupported context
"""
+ if stream is not False:
+ raise ValueError("DurableAIAgent does not support streaming mode (stream must be False)")
message_str = self._normalize_messages(messages)
run_request = self._executor.get_run_request(
@@ -128,25 +132,6 @@ def run( # type: ignore[override]
thread=thread,
)
- def run_stream( # type: ignore[override]
- self,
- messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
- *,
- thread: AgentThread | None = None,
- **kwargs: Any,
- ) -> AsyncIterator[AgentResponseUpdate]:
- """Run the agent with streaming (not supported for durable agents).
-
- Args:
- messages: The message(s) to send to the agent
- thread: Optional agent thread for conversation context
- **kwargs: Additional arguments
-
- Raises:
- NotImplementedError: Streaming is not supported for durable agents
- """
- raise NotImplementedError("Streaming is not supported for durable agents")
-
def get_new_thread(self, **kwargs: Any) -> DurableAgentThread:
"""Create a new agent thread via the provider."""
return self._executor.get_new_thread(self.name, **kwargs)
diff --git a/python/packages/durabletask/pyproject.toml b/python/packages/durabletask/pyproject.toml
index e8b66c59ab..99460344fc 100644
--- a/python/packages/durabletask/pyproject.toml
+++ b/python/packages/durabletask/pyproject.toml
@@ -45,6 +45,7 @@ environments = [
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
+pythonpath = ["tests/integration_tests"]
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
diff --git a/python/packages/durabletask/tests/integration_tests/conftest.py b/python/packages/durabletask/tests/integration_tests/conftest.py
index 2cd045f291..e6b26e33a1 100644
--- a/python/packages/durabletask/tests/integration_tests/conftest.py
+++ b/python/packages/durabletask/tests/integration_tests/conftest.py
@@ -2,8 +2,10 @@
"""Pytest configuration and fixtures for durabletask integration tests."""
import asyncio
+import json
import logging
import os
+import socket
import subprocess
import sys
import time
@@ -11,14 +13,15 @@
from collections.abc import Generator
from pathlib import Path
from typing import Any, cast
+from urllib.parse import urlparse
import pytest
import redis.asyncio as aioredis
from dotenv import load_dotenv
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
+from durabletask.client import OrchestrationStatus
-# Add the integration_tests directory to the path so testutils can be imported
-sys.path.insert(0, str(Path(__file__).parent))
+from agent_framework_durabletask import DurableAIAgentClient
# Load environment variables from .env file
load_dotenv(Path(__file__).parent / ".env")
@@ -27,6 +30,11 @@
logging.basicConfig(level=logging.WARNING)
+# =============================================================================
+# Environment and Service Checks
+# =============================================================================
+
+
def _get_dts_endpoint() -> str:
"""Get the DTS endpoint from environment or use default."""
return os.getenv("ENDPOINT", "http://localhost:8080")
@@ -36,13 +44,13 @@ def _check_dts_available(endpoint: str | None = None) -> bool:
"""Check if DTS emulator is available at the given endpoint."""
try:
resolved_endpoint: str = _get_dts_endpoint() if endpoint is None else endpoint
- DurableTaskSchedulerClient(
- host_address=resolved_endpoint,
- secure_channel=False,
- taskhub="test",
- token_credential=None,
- )
- return True
+ parsed = urlparse(resolved_endpoint)
+ host = parsed.hostname or "localhost"
+ port = parsed.port or 8080
+
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.settimeout(2)
+ return sock.connect_ex((host, port)) == 0
except Exception:
return False
@@ -66,6 +74,207 @@ async def test_connection() -> bool:
return False
+# =============================================================================
+# Client Factory Functions
+# =============================================================================
+
+
+def create_dts_client(endpoint: str, taskhub: str) -> DurableTaskSchedulerClient:
+ """Create a DurableTaskSchedulerClient with common configuration.
+
+ Args:
+ endpoint: The DTS endpoint address
+ taskhub: The task hub name
+
+ Returns:
+ A configured DurableTaskSchedulerClient instance
+ """
+ return DurableTaskSchedulerClient(
+ host_address=endpoint,
+ secure_channel=False,
+ taskhub=taskhub,
+ token_credential=None,
+ )
+
+
+def create_agent_client(
+ endpoint: str,
+ taskhub: str,
+ max_poll_retries: int = 90,
+) -> tuple[DurableTaskSchedulerClient, DurableAIAgentClient]:
+ """Create a DurableAIAgentClient with the underlying DTS client.
+
+ Args:
+ endpoint: The DTS endpoint address
+ taskhub: The task hub name
+ max_poll_retries: Max poll retries for the agent client
+
+ Returns:
+ A tuple of (DurableTaskSchedulerClient, DurableAIAgentClient)
+ """
+ dts_client = create_dts_client(endpoint, taskhub)
+ agent_client = DurableAIAgentClient(dts_client, max_poll_retries=max_poll_retries)
+ return dts_client, agent_client
+
+
+# =============================================================================
+# Orchestration Helper Class
+# =============================================================================
+
+
+class OrchestrationHelper:
+ """Helper class for orchestration-related test operations."""
+
+ def __init__(self, dts_client: DurableTaskSchedulerClient):
+ """Initialize the orchestration helper.
+
+ Args:
+ dts_client: The DurableTaskSchedulerClient instance to use
+ """
+ self.client = dts_client
+
+ def wait_for_orchestration(
+ self,
+ instance_id: str,
+ timeout: float = 60.0,
+ ) -> Any:
+ """Wait for an orchestration to complete.
+
+ Args:
+ instance_id: The orchestration instance ID
+ timeout: Maximum time to wait in seconds
+
+ Returns:
+ The final OrchestrationMetadata
+
+ Raises:
+ TimeoutError: If the orchestration doesn't complete within timeout
+ RuntimeError: If the orchestration fails
+ """
+ # Use the built-in wait_for_orchestration_completion method
+ metadata = self.client.wait_for_orchestration_completion(
+ instance_id=instance_id,
+ timeout=int(timeout),
+ )
+
+ if metadata is None:
+ raise TimeoutError(f"Orchestration {instance_id} did not complete within {timeout} seconds")
+
+ # Check if failed or terminated
+ if metadata.runtime_status == OrchestrationStatus.FAILED:
+ raise RuntimeError(f"Orchestration {instance_id} failed: {metadata.serialized_custom_status}")
+ if metadata.runtime_status == OrchestrationStatus.TERMINATED:
+ raise RuntimeError(f"Orchestration {instance_id} was terminated")
+
+ return metadata
+
+ def wait_for_orchestration_with_output(
+ self,
+ instance_id: str,
+ timeout: float = 60.0,
+ ) -> tuple[Any, Any]:
+ """Wait for an orchestration to complete and return its output.
+
+ Args:
+ instance_id: The orchestration instance ID
+ timeout: Maximum time to wait in seconds
+
+ Returns:
+ A tuple of (OrchestrationMetadata, output)
+
+ Raises:
+ TimeoutError: If the orchestration doesn't complete within timeout
+ RuntimeError: If the orchestration fails
+ """
+ metadata = self.wait_for_orchestration(instance_id, timeout)
+
+ # The output should be available in the metadata
+ return metadata, metadata.serialized_output
+
+ def get_orchestration_status(self, instance_id: str) -> Any | None:
+ """Get the current status of an orchestration.
+
+ Args:
+ instance_id: The orchestration instance ID
+
+ Returns:
+ The OrchestrationMetadata or None if not found
+ """
+ try:
+ # Try to wait with a short timeout to get current status
+ return self.client.wait_for_orchestration_completion(
+ instance_id=instance_id,
+ timeout=1, # Very short timeout, just checking status
+ )
+ except Exception:
+ return None
+
+ def raise_event(
+ self,
+ instance_id: str,
+ event_name: str,
+ event_data: Any = None,
+ ) -> None:
+ """Raise an external event to an orchestration.
+
+ Args:
+ instance_id: The orchestration instance ID
+ event_name: The name of the event
+ event_data: The event data payload
+ """
+ self.client.raise_orchestration_event(instance_id, event_name, data=event_data)
+
+ def wait_for_notification(self, instance_id: str, timeout_seconds: int = 30) -> bool:
+ """Wait for the orchestration to reach a notification point.
+
+ Polls the orchestration status until it appears to be waiting for approval.
+
+ Args:
+ instance_id: The orchestration instance ID
+ timeout_seconds: Maximum time to wait
+
+ Returns:
+ True if notification detected, False if timeout
+ """
+ start_time = time.time()
+ while time.time() - start_time < timeout_seconds:
+ try:
+ metadata = self.client.get_orchestration_state(
+ instance_id=instance_id,
+ )
+
+ if metadata:
+ # Check if we're waiting for approval by examining custom status
+ if metadata.serialized_custom_status:
+ try:
+ custom_status = json.loads(metadata.serialized_custom_status)
+ # Handle both string and dict custom status
+ status_str = custom_status if isinstance(custom_status, str) else str(custom_status)
+ if status_str.lower().startswith("requesting human feedback"):
+ return True
+ except (json.JSONDecodeError, AttributeError):
+ # If it's not JSON, treat as plain string
+ if metadata.serialized_custom_status.lower().startswith("requesting human feedback"):
+ return True
+
+ # Check for terminal states
+ if metadata.runtime_status.name == "COMPLETED" or metadata.runtime_status.name == "FAILED":
+ return False
+ except Exception:
+ # Silently ignore transient errors during polling (e.g., network issues, service unavailable).
+ # The loop will retry until timeout, allowing the service to recover.
+ pass
+
+ time.sleep(1)
+
+ return False
+
+
+# =============================================================================
+# Pytest Configuration
+# =============================================================================
+
+
def pytest_configure(config: pytest.Config) -> None:
"""Register custom markers."""
config.addinivalue_line("markers", "integration_test: mark test as integration test")
@@ -109,6 +318,11 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item
item.add_marker(skip_redis)
+# =============================================================================
+# Pytest Fixtures
+# =============================================================================
+
+
@pytest.fixture(scope="session")
def dts_endpoint() -> str:
"""Get the DTS endpoint from environment or use default."""
@@ -149,8 +363,7 @@ def worker_process(
unique_taskhub: str,
request: pytest.FixtureRequest,
) -> Generator[dict[str, Any], None, None]:
- """
- Start a worker process for the current test module by running the sample worker.py.
+ """Start a worker process for the current test module by running the sample worker.py.
This fixture:
1. Determines which sample to run from @pytest.mark.sample()
@@ -205,7 +418,15 @@ class TestSingleAgent:
pytest.fail(f"Failed to start worker subprocess: {e}")
# Wait for worker to initialize
- time.sleep(2)
+ # The worker needs time to:
+ # 1. Start Python and import modules
+ # 2. Create Azure OpenAI clients
+ # 3. Register agents with the DTS worker
+ # 4. Connect to DTS and be ready to receive signals
+ #
+ # We use a generous wait time because CI environments can be slow,
+ # and the first test that runs depends on the worker being fully ready.
+ time.sleep(8)
# Check if process is still running
if process.poll() is not None:
@@ -232,3 +453,33 @@ class TestSingleAgent:
process.wait()
except Exception as e:
logging.warning(f"Error during worker process cleanup: {e}")
+
+
+@pytest.fixture(scope="module")
+def orchestration_helper(worker_process: dict[str, Any]) -> OrchestrationHelper:
+ """Create an OrchestrationHelper for the current test module."""
+ dts_client = create_dts_client(worker_process["endpoint"], worker_process["taskhub"])
+ return OrchestrationHelper(dts_client)
+
+
+@pytest.fixture(scope="module")
+def agent_client_factory(worker_process: dict[str, Any]) -> type:
+ """Return a factory class for creating agent clients.
+
+ Usage in tests:
+ def test_example(self, agent_client_factory):
+ dts_client, agent_client = agent_client_factory.create(max_poll_retries=90)
+ """
+
+ class AgentClientFactory:
+ """Factory for creating DTS and Agent client pairs."""
+
+ endpoint = worker_process["endpoint"]
+ taskhub = worker_process["taskhub"]
+
+ @classmethod
+ def create(cls, max_poll_retries: int = 90) -> tuple[DurableTaskSchedulerClient, DurableAIAgentClient]:
+ """Create a DTS client and Agent client pair."""
+ return create_agent_client(cls.endpoint, cls.taskhub, max_poll_retries)
+
+ return AgentClientFactory
diff --git a/python/packages/durabletask/tests/integration_tests/dt_testutils.py b/python/packages/durabletask/tests/integration_tests/dt_testutils.py
deleted file mode 100644
index 34696b42ff..0000000000
--- a/python/packages/durabletask/tests/integration_tests/dt_testutils.py
+++ /dev/null
@@ -1,205 +0,0 @@
-# Copyright (c) Microsoft. All rights reserved.
-
-"""Test utilities for durabletask integration tests."""
-
-import json
-import time
-from typing import Any
-
-from durabletask.azuremanaged.client import DurableTaskSchedulerClient
-from durabletask.client import OrchestrationStatus
-
-from agent_framework_durabletask import DurableAIAgentClient
-
-
-def create_dts_client(endpoint: str, taskhub: str) -> DurableTaskSchedulerClient:
- """
- Create a DurableTaskSchedulerClient with common configuration.
-
- Args:
- endpoint: The DTS endpoint address
- taskhub: The task hub name
-
- Returns:
- A configured DurableTaskSchedulerClient instance
- """
- return DurableTaskSchedulerClient(
- host_address=endpoint,
- secure_channel=False,
- taskhub=taskhub,
- token_credential=None,
- )
-
-
-def create_agent_client(
- endpoint: str,
- taskhub: str,
- max_poll_retries: int = 90,
-) -> tuple[DurableTaskSchedulerClient, DurableAIAgentClient]:
- """
- Create a DurableAIAgentClient with the underlying DTS client.
-
- Args:
- endpoint: The DTS endpoint address
- taskhub: The task hub name
- max_poll_retries: Max poll retries for the agent client
-
- Returns:
- A tuple of (DurableTaskSchedulerClient, DurableAIAgentClient)
- """
- dts_client = create_dts_client(endpoint, taskhub)
- agent_client = DurableAIAgentClient(dts_client, max_poll_retries=max_poll_retries)
- return dts_client, agent_client
-
-
-class OrchestrationHelper:
- """Helper class for orchestration-related test operations."""
-
- def __init__(self, dts_client: DurableTaskSchedulerClient):
- """
- Initialize the orchestration helper.
-
- Args:
- dts_client: The DurableTaskSchedulerClient instance to use
- """
- self.client = dts_client
-
- def wait_for_orchestration(
- self,
- instance_id: str,
- timeout: float = 60.0,
- ) -> Any:
- """
- Wait for an orchestration to complete.
-
- Args:
- instance_id: The orchestration instance ID
- timeout: Maximum time to wait in seconds
-
- Returns:
- The final OrchestrationMetadata
-
- Raises:
- TimeoutError: If the orchestration doesn't complete within timeout
- RuntimeError: If the orchestration fails
- """
- # Use the built-in wait_for_orchestration_completion method
- metadata = self.client.wait_for_orchestration_completion(
- instance_id=instance_id,
- timeout=int(timeout),
- )
-
- if metadata is None:
- raise TimeoutError(f"Orchestration {instance_id} did not complete within {timeout} seconds")
-
- # Check if failed or terminated
- if metadata.runtime_status == OrchestrationStatus.FAILED:
- raise RuntimeError(f"Orchestration {instance_id} failed: {metadata.serialized_custom_status}")
- if metadata.runtime_status == OrchestrationStatus.TERMINATED:
- raise RuntimeError(f"Orchestration {instance_id} was terminated")
-
- return metadata
-
- def wait_for_orchestration_with_output(
- self,
- instance_id: str,
- timeout: float = 60.0,
- ) -> tuple[Any, Any]:
- """
- Wait for an orchestration to complete and return its output.
-
- Args:
- instance_id: The orchestration instance ID
- timeout: Maximum time to wait in seconds
-
- Returns:
- A tuple of (OrchestrationMetadata, output)
-
- Raises:
- TimeoutError: If the orchestration doesn't complete within timeout
- RuntimeError: If the orchestration fails
- """
- metadata = self.wait_for_orchestration(instance_id, timeout)
-
- # The output should be available in the metadata
- return metadata, metadata.serialized_output
-
- def get_orchestration_status(self, instance_id: str) -> Any | None:
- """
- Get the current status of an orchestration.
-
- Args:
- instance_id: The orchestration instance ID
-
- Returns:
- The OrchestrationMetadata or None if not found
- """
- try:
- # Try to wait with a short timeout to get current status
- return self.client.wait_for_orchestration_completion(
- instance_id=instance_id,
- timeout=1, # Very short timeout, just checking status
- )
- except Exception:
- return None
-
- def raise_event(
- self,
- instance_id: str,
- event_name: str,
- event_data: Any = None,
- ) -> None:
- """
- Raise an external event to an orchestration.
-
- Args:
- instance_id: The orchestration instance ID
- event_name: The name of the event
- event_data: The event data payload
- """
- self.client.raise_orchestration_event(instance_id, event_name, data=event_data)
-
- def wait_for_notification(self, instance_id: str, timeout_seconds: int = 30) -> bool:
- """Wait for the orchestration to reach a notification point.
-
- Polls the orchestration status until it appears to be waiting for approval.
-
- Args:
- instance_id: The orchestration instance ID
- timeout_seconds: Maximum time to wait
-
- Returns:
- True if notification detected, False if timeout
- """
- start_time = time.time()
- while time.time() - start_time < timeout_seconds:
- try:
- metadata = self.client.get_orchestration_state(
- instance_id=instance_id,
- )
-
- if metadata:
- # Check if we're waiting for approval by examining custom status
- if metadata.serialized_custom_status:
- try:
- custom_status = json.loads(metadata.serialized_custom_status)
- # Handle both string and dict custom status
- status_str = custom_status if isinstance(custom_status, str) else str(custom_status)
- if status_str.lower().startswith("requesting human feedback"):
- return True
- except (json.JSONDecodeError, AttributeError):
- # If it's not JSON, treat as plain string
- if metadata.serialized_custom_status.lower().startswith("requesting human feedback"):
- return True
-
- # Check for terminal states
- if metadata.runtime_status.name == "COMPLETED" or metadata.runtime_status.name == "FAILED":
- return False
- except Exception:
- # Silently ignore transient errors during polling (e.g., network issues, service unavailable).
- # The loop will retry until timeout, allowing the service to recover.
- pass
-
- time.sleep(1)
-
- return False
diff --git a/python/packages/durabletask/tests/integration_tests/test_01_dt_single_agent.py b/python/packages/durabletask/tests/integration_tests/test_01_dt_single_agent.py
index 38ca54050c..b87e078345 100644
--- a/python/packages/durabletask/tests/integration_tests/test_01_dt_single_agent.py
+++ b/python/packages/durabletask/tests/integration_tests/test_01_dt_single_agent.py
@@ -10,10 +10,7 @@
- Empty thread ID handling
"""
-from typing import Any
-
import pytest
-from dt_testutils import create_agent_client
# Module-level markers - applied to all tests in this module
pytestmark = [
@@ -28,13 +25,10 @@ class TestSingleAgent:
"""Test suite for single agent functionality."""
@pytest.fixture(autouse=True)
- def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
+ def setup(self, agent_client_factory: type) -> None:
"""Setup test fixtures."""
- self.endpoint: str = dts_endpoint
- self.taskhub: str = str(worker_process["taskhub"])
-
- # Create agent client
- _, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
+ # Create agent client using the factory fixture
+ _, self.agent_client = agent_client_factory.create()
def test_agent_registration(self) -> None:
"""Test that the Joker agent is registered and accessible."""
diff --git a/python/packages/durabletask/tests/integration_tests/test_02_dt_multi_agent.py b/python/packages/durabletask/tests/integration_tests/test_02_dt_multi_agent.py
index da5f12abe4..02bcd3029a 100644
--- a/python/packages/durabletask/tests/integration_tests/test_02_dt_multi_agent.py
+++ b/python/packages/durabletask/tests/integration_tests/test_02_dt_multi_agent.py
@@ -10,10 +10,7 @@
- Agent isolation and tool routing
"""
-from typing import Any
-
import pytest
-from dt_testutils import create_agent_client
# Agent names from the 02_multi_agent sample
WEATHER_AGENT_NAME: str = "WeatherAgent"
@@ -32,13 +29,10 @@ class TestMultiAgent:
"""Test suite for multi-agent functionality."""
@pytest.fixture(autouse=True)
- def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
+ def setup(self, agent_client_factory: type) -> None:
"""Setup test fixtures."""
- self.endpoint: str = dts_endpoint
- self.taskhub: str = str(worker_process["taskhub"])
-
- # Create agent client
- _, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
+ # Create agent client using the factory fixture
+ _, self.agent_client = agent_client_factory.create()
def test_multiple_agents_registered(self) -> None:
"""Test that both agents are registered and accessible."""
diff --git a/python/packages/durabletask/tests/integration_tests/test_03_dt_single_agent_streaming.py b/python/packages/durabletask/tests/integration_tests/test_03_dt_single_agent_streaming.py
index d127a87356..2d05280431 100644
--- a/python/packages/durabletask/tests/integration_tests/test_03_dt_single_agent_streaming.py
+++ b/python/packages/durabletask/tests/integration_tests/test_03_dt_single_agent_streaming.py
@@ -22,11 +22,9 @@
import time
from datetime import timedelta
from pathlib import Path
-from typing import Any
import pytest
import redis.asyncio as aioredis
-from dt_testutils import OrchestrationHelper, create_agent_client
# Add sample directory to path to import RedisStreamResponseHandler
SAMPLE_DIR = Path(__file__).parents[4] / "samples" / "getting_started" / "durabletask" / "03_single_agent_streaming"
@@ -48,14 +46,11 @@ class TestSampleReliableStreaming:
"""Tests for 03_single_agent_streaming sample."""
@pytest.fixture(autouse=True)
- def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
+ def setup(self, agent_client_factory: type, orchestration_helper) -> None:
"""Setup test fixtures."""
- self.endpoint: str = dts_endpoint
- self.taskhub: str = str(worker_process["taskhub"])
-
- # Create agent client
- dts_client, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
- self.helper = OrchestrationHelper(dts_client)
+ # Create agent client using the factory fixture
+ _, self.agent_client = agent_client_factory.create()
+ self.helper = orchestration_helper
# Redis configuration
self.redis_connection_string = os.environ.get("REDIS_CONNECTION_STRING", "redis://localhost:6379")
diff --git a/python/packages/durabletask/tests/integration_tests/test_04_dt_single_agent_orchestration_chaining.py b/python/packages/durabletask/tests/integration_tests/test_04_dt_single_agent_orchestration_chaining.py
index 85cdde270e..27508a6ddd 100644
--- a/python/packages/durabletask/tests/integration_tests/test_04_dt_single_agent_orchestration_chaining.py
+++ b/python/packages/durabletask/tests/integration_tests/test_04_dt_single_agent_orchestration_chaining.py
@@ -11,10 +11,8 @@
import json
import logging
-from typing import Any
import pytest
-from dt_testutils import OrchestrationHelper, create_agent_client
from durabletask.client import OrchestrationStatus
# Agent name from the 04_single_agent_orchestration_chaining sample
@@ -36,16 +34,11 @@ class TestSingleAgentOrchestrationChaining:
"""Test suite for single agent orchestration with chaining."""
@pytest.fixture(autouse=True)
- def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
+ def setup(self, agent_client_factory: type, orchestration_helper) -> None:
"""Setup test fixtures."""
- self.endpoint: str = dts_endpoint
- self.taskhub: str = str(worker_process["taskhub"])
-
- # Create agent client and DTS client
- self.dts_client, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
-
- # Create orchestration helper
- self.orch_helper = OrchestrationHelper(self.dts_client)
+ # Create agent client using the factory fixture
+ self.dts_client, self.agent_client = agent_client_factory.create()
+ self.orch_helper = orchestration_helper
def test_agent_registered(self):
"""Test that the Writer agent is registered."""
diff --git a/python/packages/durabletask/tests/integration_tests/test_05_dt_multi_agent_orchestration_concurrency.py b/python/packages/durabletask/tests/integration_tests/test_05_dt_multi_agent_orchestration_concurrency.py
index 367100ef0c..c13b07c01e 100644
--- a/python/packages/durabletask/tests/integration_tests/test_05_dt_multi_agent_orchestration_concurrency.py
+++ b/python/packages/durabletask/tests/integration_tests/test_05_dt_multi_agent_orchestration_concurrency.py
@@ -11,10 +11,8 @@
import json
import logging
-from typing import Any
import pytest
-from dt_testutils import OrchestrationHelper, create_agent_client
from durabletask.client import OrchestrationStatus
# Agent names from the 05_multi_agent_orchestration_concurrency sample
@@ -36,16 +34,11 @@ class TestMultiAgentOrchestrationConcurrency:
"""Test suite for multi-agent orchestration with concurrency."""
@pytest.fixture(autouse=True)
- def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
+ def setup(self, agent_client_factory: type, orchestration_helper) -> None:
"""Setup test fixtures."""
- self.endpoint = dts_endpoint
- self.taskhub = worker_process["taskhub"]
-
- # Create agent client and DTS client
- self.dts_client, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
-
- # Create orchestration helper
- self.orch_helper = OrchestrationHelper(self.dts_client)
+ # Create agent client using the factory fixture
+ self.dts_client, self.agent_client = agent_client_factory.create()
+ self.orch_helper = orchestration_helper
def test_agents_registered(self):
"""Test that both agents are registered."""
diff --git a/python/packages/durabletask/tests/integration_tests/test_06_dt_multi_agent_orchestration_conditionals.py b/python/packages/durabletask/tests/integration_tests/test_06_dt_multi_agent_orchestration_conditionals.py
index 9642cd3672..1fc59279f9 100644
--- a/python/packages/durabletask/tests/integration_tests/test_06_dt_multi_agent_orchestration_conditionals.py
+++ b/python/packages/durabletask/tests/integration_tests/test_06_dt_multi_agent_orchestration_conditionals.py
@@ -11,10 +11,8 @@
"""
import logging
-from typing import Any
import pytest
-from dt_testutils import OrchestrationHelper, create_agent_client
from durabletask.client import OrchestrationStatus
# Agent names from the 06_multi_agent_orchestration_conditionals sample
@@ -36,16 +34,11 @@ class TestMultiAgentOrchestrationConditionals:
"""Test suite for multi-agent orchestration with conditionals."""
@pytest.fixture(autouse=True)
- def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
+ def setup(self, agent_client_factory: type, orchestration_helper) -> None:
"""Setup test fixtures."""
- self.endpoint: str = dts_endpoint
- self.taskhub: str = str(worker_process["taskhub"])
-
- # Create agent client and DTS client
- self.dts_client, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
-
- # Create orchestration helper
- self.orch_helper = OrchestrationHelper(self.dts_client)
+ # Create agent client using the factory fixture
+ self.dts_client, self.agent_client = agent_client_factory.create()
+ self.orch_helper = orchestration_helper
def test_agents_registered(self):
"""Test that both agents are registered."""
diff --git a/python/packages/durabletask/tests/integration_tests/test_07_dt_single_agent_orchestration_hitl.py b/python/packages/durabletask/tests/integration_tests/test_07_dt_single_agent_orchestration_hitl.py
index 2a668e9ede..fa713aaec7 100644
--- a/python/packages/durabletask/tests/integration_tests/test_07_dt_single_agent_orchestration_hitl.py
+++ b/python/packages/durabletask/tests/integration_tests/test_07_dt_single_agent_orchestration_hitl.py
@@ -11,10 +11,8 @@
"""
import logging
-from typing import Any
import pytest
-from dt_testutils import OrchestrationHelper, create_agent_client
from durabletask.client import OrchestrationStatus
# Constants from the 07_single_agent_orchestration_hitl sample
@@ -36,18 +34,11 @@ class TestSingleAgentOrchestrationHITL:
"""Test suite for single agent orchestration with human-in-the-loop."""
@pytest.fixture(autouse=True)
- def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
+ def setup(self, agent_client_factory: type, orchestration_helper) -> None:
"""Setup test fixtures."""
- self.endpoint: str = str(worker_process["endpoint"])
- self.taskhub: str = str(worker_process["taskhub"])
-
- logging.info(f"Using taskhub: {self.taskhub} at endpoint: {self.endpoint}")
-
- # Create agent client and DTS client
- self.dts_client, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
-
- # Create orchestration helper
- self.orch_helper = OrchestrationHelper(self.dts_client)
+ # Create agent client using the factory fixture
+ self.dts_client, self.agent_client = agent_client_factory.create()
+ self.orch_helper = orchestration_helper
def test_agent_registered(self):
"""Test that the Writer agent is registered."""
diff --git a/python/packages/durabletask/tests/test_durable_entities.py b/python/packages/durabletask/tests/test_durable_entities.py
index acebcd8492..e4516f1ce3 100644
--- a/python/packages/durabletask/tests/test_durable_entities.py
+++ b/python/packages/durabletask/tests/test_durable_entities.py
@@ -11,7 +11,7 @@
from unittest.mock import AsyncMock, Mock
import pytest
-from agent_framework import AgentResponse, AgentResponseUpdate, ChatMessage, Content
+from agent_framework import AgentResponse, AgentResponseUpdate, ChatMessage, Content, ResponseStream
from pydantic import BaseModel
from agent_framework_durabletask import (
@@ -81,8 +81,27 @@ def _role_value(chat_message: DurableAgentStateMessage) -> str:
def _agent_response(text: str | None) -> AgentResponse:
"""Create an AgentResponse with a single assistant message."""
- message = ChatMessage("assistant", [text]) if text is not None else ChatMessage("assistant", [])
- return AgentResponse(messages=[message])
+ message = ChatMessage(role="assistant", text=text) if text is not None else ChatMessage(role="assistant", text="")
+ return AgentResponse(messages=[message], created_at="2024-01-01T00:00:00Z")
+
+
+def _create_mock_run(response: AgentResponse | None = None, side_effect: Exception | None = None):
+ """Create a mock run function that handles stream parameter correctly.
+
+ The durabletask entity code tries run(stream=True) first, then falls back to run(stream=False).
+ This helper creates a mock that raises TypeError for streaming (to trigger fallback) and
+ returns the response or raises the side_effect for non-streaming.
+ """
+
+ async def mock_run(*args, stream=False, **kwargs):
+ if stream:
+ # Simulate "streaming not supported" to trigger fallback
+ raise TypeError("streaming not supported")
+ if side_effect:
+ raise side_effect
+ return response
+
+ return mock_run
class RecordingCallback:
@@ -194,7 +213,14 @@ async def test_run_executes_agent(self) -> None:
"""Test that run executes the agent."""
mock_agent = Mock()
mock_response = _agent_response("Test response")
- mock_agent.run = AsyncMock(return_value=mock_response)
+
+ # Mock run() to return response for non-streaming, raise for streaming (to test fallback)
+ async def mock_run(*args, stream=False, **kwargs):
+ if stream:
+ raise TypeError("streaming not supported")
+ return mock_response
+
+ mock_agent.run = mock_run
entity = _make_entity(mock_agent)
@@ -203,22 +229,12 @@ async def test_run_executes_agent(self) -> None:
"correlationId": "corr-entity-1",
})
- # Verify agent.run was called
- mock_agent.run.assert_called_once()
- _, kwargs = mock_agent.run.call_args
- sent_messages: list[Any] = kwargs.get("messages")
- assert len(sent_messages) == 1
- sent_message = sent_messages[0]
- assert isinstance(sent_message, ChatMessage)
- assert getattr(sent_message, "text", None) == "Test message"
- assert getattr(sent_message.role, "value", sent_message.role) == "user"
-
# Verify result
assert isinstance(result, AgentResponse)
assert result.text == "Test response"
async def test_run_agent_streaming_callbacks_invoked(self) -> None:
- """Ensure streaming updates trigger callbacks and run() is not used."""
+ """Ensure streaming updates trigger callbacks when using run(stream=True)."""
updates = [
AgentResponseUpdate(contents=[Content.from_text(text="Hello")]),
AgentResponseUpdate(contents=[Content.from_text(text=" world")]),
@@ -230,8 +246,17 @@ async def update_generator() -> AsyncIterator[AgentResponseUpdate]:
mock_agent = Mock()
mock_agent.name = "StreamingAgent"
- mock_agent.run_stream = Mock(return_value=update_generator())
- mock_agent.run = AsyncMock(side_effect=AssertionError("run() should not be called when streaming succeeds"))
+
+ # Mock run() to return ResponseStream when stream=True
+ def mock_run(*args, stream=False, **kwargs):
+ if stream:
+ return ResponseStream(
+ update_generator(),
+ finalizer=AgentResponse.from_updates,
+ )
+ raise AssertionError("run(stream=False) should not be called when streaming succeeds")
+
+ mock_agent.run = mock_run
callback = RecordingCallback()
entity = _make_entity(mock_agent, callback=callback, thread_id="session-1")
@@ -247,7 +272,6 @@ async def update_generator() -> AsyncIterator[AgentResponseUpdate]:
assert "Hello" in result.text
assert callback.stream_mock.await_count == len(updates)
assert callback.response_mock.await_count == 1
- mock_agent.run.assert_not_called()
# Validate callback arguments
stream_calls = callback.stream_mock.await_args_list
@@ -272,9 +296,8 @@ async def test_run_agent_final_callback_without_streaming(self) -> None:
"""Ensure the final callback fires even when streaming is unavailable."""
mock_agent = Mock()
mock_agent.name = "NonStreamingAgent"
- mock_agent.run_stream = None
agent_response = _agent_response("Final response")
- mock_agent.run = AsyncMock(return_value=agent_response)
+ mock_agent.run = _create_mock_run(response=agent_response)
callback = RecordingCallback()
entity = _make_entity(mock_agent, callback=callback, thread_id="session-2")
@@ -304,7 +327,7 @@ async def test_run_agent_updates_conversation_history(self) -> None:
"""Test that run_agent updates the conversation history."""
mock_agent = Mock()
mock_response = _agent_response("Agent response")
- mock_agent.run = AsyncMock(return_value=mock_response)
+ mock_agent.run = _create_mock_run(response=mock_response)
entity = _make_entity(mock_agent)
@@ -327,7 +350,7 @@ async def test_run_agent_updates_conversation_history(self) -> None:
async def test_run_agent_increments_message_count(self) -> None:
"""Test that run_agent increments the message count."""
mock_agent = Mock()
- mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
+ mock_agent.run = _create_mock_run(response=_agent_response("Response"))
entity = _make_entity(mock_agent)
@@ -345,7 +368,7 @@ async def test_run_agent_increments_message_count(self) -> None:
async def test_run_requires_entity_thread_id(self) -> None:
"""Test that AgentEntity.run rejects missing entity thread identifiers."""
mock_agent = Mock()
- mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
+ mock_agent.run = _create_mock_run(response=_agent_response("Response"))
entity = _make_entity(mock_agent, thread_id="")
@@ -355,7 +378,7 @@ async def test_run_requires_entity_thread_id(self) -> None:
async def test_run_agent_multiple_conversations(self) -> None:
"""Test that run_agent maintains history across multiple messages."""
mock_agent = Mock()
- mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
+ mock_agent.run = _create_mock_run(response=_agent_response("Response"))
entity = _make_entity(mock_agent)
@@ -419,7 +442,7 @@ def test_reset_clears_message_count(self) -> None:
async def test_reset_after_conversation(self) -> None:
"""Test reset after a full conversation."""
mock_agent = Mock()
- mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
+ mock_agent.run = _create_mock_run(response=_agent_response("Response"))
entity = _make_entity(mock_agent)
@@ -445,7 +468,7 @@ class TestErrorHandling:
async def test_run_agent_handles_agent_exception(self) -> None:
"""Test that run_agent handles agent exceptions."""
mock_agent = Mock()
- mock_agent.run = AsyncMock(side_effect=Exception("Agent failed"))
+ mock_agent.run = _create_mock_run(side_effect=Exception("Agent failed"))
entity = _make_entity(mock_agent)
@@ -461,7 +484,7 @@ async def test_run_agent_handles_agent_exception(self) -> None:
async def test_run_agent_handles_value_error(self) -> None:
"""Test that run_agent handles ValueError instances."""
mock_agent = Mock()
- mock_agent.run = AsyncMock(side_effect=ValueError("Invalid input"))
+ mock_agent.run = _create_mock_run(side_effect=ValueError("Invalid input"))
entity = _make_entity(mock_agent)
@@ -477,7 +500,7 @@ async def test_run_agent_handles_value_error(self) -> None:
async def test_run_agent_handles_timeout_error(self) -> None:
"""Test that run_agent handles TimeoutError instances."""
mock_agent = Mock()
- mock_agent.run = AsyncMock(side_effect=TimeoutError("Request timeout"))
+ mock_agent.run = _create_mock_run(side_effect=TimeoutError("Request timeout"))
entity = _make_entity(mock_agent)
@@ -492,7 +515,7 @@ async def test_run_agent_handles_timeout_error(self) -> None:
async def test_run_agent_preserves_message_on_error(self) -> None:
"""Test that run_agent preserves message information on error."""
mock_agent = Mock()
- mock_agent.run = AsyncMock(side_effect=Exception("Error"))
+ mock_agent.run = _create_mock_run(side_effect=Exception("Error"))
entity = _make_entity(mock_agent)
@@ -513,7 +536,7 @@ class TestConversationHistory:
async def test_conversation_history_has_timestamps(self) -> None:
"""Test that conversation history entries include timestamps."""
mock_agent = Mock()
- mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
+ mock_agent.run = _create_mock_run(response=_agent_response("Response"))
entity = _make_entity(mock_agent)
@@ -533,17 +556,17 @@ async def test_conversation_history_ordering(self) -> None:
entity = _make_entity(mock_agent)
# Send multiple messages with different responses
- mock_agent.run = AsyncMock(return_value=_agent_response("Response 1"))
+ mock_agent.run = _create_mock_run(response=_agent_response("Response 1"))
await entity.run(
{"message": "Message 1", "correlationId": "corr-entity-history-2a"},
)
- mock_agent.run = AsyncMock(return_value=_agent_response("Response 2"))
+ mock_agent.run = _create_mock_run(response=_agent_response("Response 2"))
await entity.run(
{"message": "Message 2", "correlationId": "corr-entity-history-2b"},
)
- mock_agent.run = AsyncMock(return_value=_agent_response("Response 3"))
+ mock_agent.run = _create_mock_run(response=_agent_response("Response 3"))
await entity.run(
{"message": "Message 3", "correlationId": "corr-entity-history-2c"},
)
@@ -561,7 +584,7 @@ async def test_conversation_history_ordering(self) -> None:
async def test_conversation_history_role_alternation(self) -> None:
"""Test that conversation history alternates between user and assistant roles."""
mock_agent = Mock()
- mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
+ mock_agent.run = _create_mock_run(response=_agent_response("Response"))
entity = _make_entity(mock_agent)
@@ -587,7 +610,7 @@ class TestRunRequestSupport:
async def test_run_agent_with_run_request_object(self) -> None:
"""Test run_agent with a RunRequest object."""
mock_agent = Mock()
- mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
+ mock_agent.run = _create_mock_run(response=_agent_response("Response"))
entity = _make_entity(mock_agent)
@@ -606,7 +629,7 @@ async def test_run_agent_with_run_request_object(self) -> None:
async def test_run_agent_with_dict_request(self) -> None:
"""Test run_agent with a dictionary request."""
mock_agent = Mock()
- mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
+ mock_agent.run = _create_mock_run(response=_agent_response("Response"))
entity = _make_entity(mock_agent)
@@ -625,7 +648,7 @@ async def test_run_agent_with_dict_request(self) -> None:
async def test_run_agent_with_string_raises_without_correlation(self) -> None:
"""Test that run_agent rejects legacy string input without correlation ID."""
mock_agent = Mock()
- mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
+ mock_agent.run = _create_mock_run(response=_agent_response("Response"))
entity = _make_entity(mock_agent)
@@ -635,7 +658,7 @@ async def test_run_agent_with_string_raises_without_correlation(self) -> None:
async def test_run_agent_stores_role_in_history(self) -> None:
"""Test that run_agent stores the role in conversation history."""
mock_agent = Mock()
- mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
+ mock_agent.run = _create_mock_run(response=_agent_response("Response"))
entity = _make_entity(mock_agent)
@@ -657,7 +680,7 @@ async def test_run_agent_with_response_format(self) -> None:
"""Test run_agent with a JSON response format."""
mock_agent = Mock()
# Return JSON response
- mock_agent.run = AsyncMock(return_value=_agent_response('{"answer": 42}'))
+ mock_agent.run = _create_mock_run(response=_agent_response('{"answer": 42}'))
entity = _make_entity(mock_agent)
@@ -676,7 +699,7 @@ async def test_run_agent_with_response_format(self) -> None:
async def test_run_agent_disable_tool_calls(self) -> None:
"""Test run_agent with tool calls disabled."""
mock_agent = Mock()
- mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
+ mock_agent.run = _create_mock_run(response=_agent_response("Response"))
entity = _make_entity(mock_agent)
@@ -686,7 +709,7 @@ async def test_run_agent_disable_tool_calls(self) -> None:
assert isinstance(result, AgentResponse)
# Agent should have been called (tool disabling is framework-dependent)
- mock_agent.run.assert_called_once()
+ assert result.text == "Response"
if __name__ == "__main__":
diff --git a/python/packages/durabletask/tests/test_shim.py b/python/packages/durabletask/tests/test_shim.py
index d1b0cf2cab..26988edca4 100644
--- a/python/packages/durabletask/tests/test_shim.py
+++ b/python/packages/durabletask/tests/test_shim.py
@@ -77,7 +77,7 @@ def test_run_accepts_string_message(self, test_agent: DurableAIAgent[Any], mock_
def test_run_accepts_chat_message(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
"""Verify run accepts and normalizes ChatMessage objects."""
- chat_msg = ChatMessage("user", ["Test message"])
+ chat_msg = ChatMessage(role="user", text="Test message")
test_agent.run(chat_msg)
mock_executor.run_durable_agent.assert_called_once()
@@ -95,8 +95,8 @@ def test_run_accepts_list_of_strings(self, test_agent: DurableAIAgent[Any], mock
def test_run_accepts_list_of_chat_messages(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
"""Verify run accepts and joins list of ChatMessage objects."""
messages = [
- ChatMessage("user", ["Message 1"]),
- ChatMessage("assistant", ["Message 2"]),
+ ChatMessage(role="user", text="Message 1"),
+ ChatMessage(role="assistant", text="Message 2"),
]
test_agent.run(messages)
diff --git a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py
index 380bd64f7b..0ee6ce4ab0 100644
--- a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py
+++ b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py
@@ -1,13 +1,22 @@
# Copyright (c) Microsoft. All rights reserved.
+from __future__ import annotations
+
import sys
+from collections.abc import Sequence
from typing import Any, ClassVar, Generic
-from agent_framework import ChatOptions, use_chat_middleware, use_function_invocation
+from agent_framework import (
+ ChatAndFunctionMiddlewareTypes,
+ ChatMiddlewareLayer,
+ ChatOptions,
+ FunctionInvocationConfiguration,
+ FunctionInvocationLayer,
+)
from agent_framework._pydantic import AFBaseSettings
from agent_framework.exceptions import ServiceInitializationError
-from agent_framework.observability import use_instrumentation
-from agent_framework.openai._chat_client import OpenAIBaseChatClient
+from agent_framework.observability import ChatTelemetryLayer
+from agent_framework.openai._chat_client import RawOpenAIChatClient
from foundry_local import FoundryLocalManager
from foundry_local.models import DeviceType
from openai import AsyncOpenAI
@@ -22,6 +31,7 @@
else:
from typing_extensions import TypedDict # type: ignore # pragma: no cover
+
__all__ = [
"FoundryLocalChatOptions",
"FoundryLocalClient",
@@ -126,11 +136,14 @@ class FoundryLocalSettings(AFBaseSettings):
model_id: str
-@use_function_invocation
-@use_instrumentation
-@use_chat_middleware
-class FoundryLocalClient(OpenAIBaseChatClient[TFoundryLocalChatOptions], Generic[TFoundryLocalChatOptions]):
- """Foundry Local Chat completion class."""
+class FoundryLocalClient(
+ ChatMiddlewareLayer[TFoundryLocalChatOptions],
+ FunctionInvocationLayer[TFoundryLocalChatOptions],
+ ChatTelemetryLayer[TFoundryLocalChatOptions],
+ RawOpenAIChatClient[TFoundryLocalChatOptions],
+ Generic[TFoundryLocalChatOptions],
+):
+ """Foundry Local Chat completion class with middleware, telemetry, and function invocation support."""
def __init__(
self,
@@ -140,6 +153,8 @@ def __init__(
timeout: float | None = None,
prepare_model: bool = True,
device: DeviceType | None = None,
+ middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
+ function_invocation_configuration: FunctionInvocationConfiguration | None = None,
env_file_path: str | None = None,
env_file_encoding: str = "utf-8",
**kwargs: Any,
@@ -161,9 +176,11 @@ def __init__(
The device is used to select the appropriate model variant.
If not provided, the default device for your system will be used.
The values are in the foundry_local.models.DeviceType enum.
+ middleware: Optional sequence of ChatAndFunctionMiddlewareTypes to apply to requests.
+ function_invocation_configuration: Optional configuration for function invocation support.
env_file_path: If provided, the .env settings are read from this file path location.
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
- kwargs: Additional keyword arguments, are passed to the OpenAIBaseChatClient.
+ kwargs: Additional keyword arguments, are passed to the RawOpenAIChatClient.
This can include middleware and additional properties.
Examples:
@@ -254,6 +271,8 @@ class MyOptions(FoundryLocalChatOptions, total=False):
super().__init__(
model_id=model_info.id,
client=AsyncOpenAI(base_url=manager.endpoint, api_key=manager.api_key),
+ middleware=middleware,
+ function_invocation_configuration=function_invocation_configuration,
**kwargs,
)
self.manager = manager
diff --git a/python/packages/foundry_local/samples/foundry_local_agent.py b/python/packages/foundry_local/samples/foundry_local_agent.py
index 4bb704ec59..6d4705f8cb 100644
--- a/python/packages/foundry_local/samples/foundry_local_agent.py
+++ b/python/packages/foundry_local/samples/foundry_local_agent.py
@@ -48,7 +48,7 @@ async def streaming_example(agent: "ChatAgent") -> None:
query = "What's the weather like in Amsterdam?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py
index 778a340039..8fa7e3c6a2 100644
--- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py
+++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py
@@ -4,8 +4,8 @@
import contextlib
import logging
import sys
-from collections.abc import AsyncIterable, Callable, MutableMapping, Sequence
-from typing import Any, ClassVar, Generic, TypedDict
+from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence
+from typing import Any, ClassVar, Generic, Literal, TypedDict, overload
from agent_framework import (
AgentMiddlewareTypes,
@@ -16,6 +16,7 @@
ChatMessage,
Content,
ContextProvider,
+ ResponseStream,
normalize_messages,
)
from agent_framework._tools import FunctionTool, ToolProtocol
@@ -272,34 +273,79 @@ async def stop(self) -> None:
self._started = False
- async def run(
+ @overload
+ def run(
self,
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
*,
+ stream: Literal[False] = False,
thread: AgentThread | None = None,
options: TOptions | None = None,
**kwargs: Any,
- ) -> AgentResponse:
+ ) -> Awaitable[AgentResponse]: ...
+
+ @overload
+ def run(
+ self,
+ messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
+ *,
+ stream: Literal[True],
+ thread: AgentThread | None = None,
+ options: TOptions | None = None,
+ **kwargs: Any,
+ ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ...
+
+ def run(
+ self,
+ messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
+ *,
+ stream: bool = False,
+ thread: AgentThread | None = None,
+ options: TOptions | None = None,
+ **kwargs: Any,
+ ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
"""Get a response from the agent.
This method returns the final result of the agent's execution
- as a single AgentResponse object. The caller is blocked until
- the final result is available.
+ as a single AgentResponse object when stream=False. When stream=True,
+ it returns a ResponseStream that yields AgentResponseUpdate objects.
Args:
messages: The message(s) to send to the agent.
Keyword Args:
+ stream: Whether to stream the response. Defaults to False.
thread: The conversation thread associated with the message(s).
options: Runtime options (model, timeout, etc.).
kwargs: Additional keyword arguments.
Returns:
- An agent response item.
+ When stream=False: An Awaitable[AgentResponse].
+ When stream=True: A ResponseStream of AgentResponseUpdate items.
Raises:
ServiceException: If the request fails.
"""
+ if stream:
+
+ def _finalize(updates: Sequence[AgentResponseUpdate]) -> AgentResponse:
+ return AgentResponse.from_updates(updates)
+
+ return ResponseStream(
+ self._stream_updates(messages=messages, thread=thread, options=options, **kwargs),
+ finalizer=_finalize,
+ )
+ return self._run_impl(messages=messages, thread=thread, options=options, **kwargs)
+
+ async def _run_impl(
+ self,
+ messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
+ *,
+ thread: AgentThread | None = None,
+ options: TOptions | None = None,
+ **kwargs: Any,
+ ) -> AgentResponse:
+ """Non-streaming implementation of run."""
if not self._started:
await self.start()
@@ -339,7 +385,7 @@ async def run(
return AgentResponse(messages=response_messages, response_id=response_id)
- async def run_stream(
+ async def _stream_updates(
self,
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
*,
@@ -347,10 +393,7 @@ async def run_stream(
options: TOptions | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]:
- """Run the agent as a stream.
-
- This method will return the intermediate steps and final results of the
- agent's execution as a stream of AgentResponseUpdate objects to the caller.
+ """Internal method to stream updates from GitHub Copilot.
Args:
messages: The message(s) to send to the agent.
@@ -361,7 +404,7 @@ async def run_stream(
kwargs: Additional keyword arguments.
Yields:
- An agent response update for each delta.
+ AgentResponseUpdate items.
Raises:
ServiceException: If the request fails.
@@ -498,7 +541,7 @@ async def _get_or_create_session(
Args:
thread: The conversation thread.
streaming: Whether to enable streaming for the session.
- runtime_options: Runtime options from run/run_stream that take precedence.
+ runtime_options: Runtime options from run that take precedence.
Returns:
A CopilotSession instance.
diff --git a/python/packages/github_copilot/tests/__init__.py b/python/packages/github_copilot/tests/__init__.py
deleted file mode 100644
index 2a50eae894..0000000000
--- a/python/packages/github_copilot/tests/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-# Copyright (c) Microsoft. All rights reserved.
diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py
index 37707465cb..ed302b5bb6 100644
--- a/python/packages/github_copilot/tests/test_github_copilot_agent.py
+++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py
@@ -294,7 +294,7 @@ async def test_run_chat_message(
mock_session.send_and_wait.return_value = assistant_message_event
agent = GitHubCopilotAgent(client=mock_client)
- chat_message = ChatMessage("user", [Content.from_text("Hello")])
+ chat_message = ChatMessage(role="user", contents=[Content.from_text("Hello")])
response = await agent.run(chat_message)
assert isinstance(response, AgentResponse)
@@ -362,10 +362,10 @@ async def test_run_auto_starts(
mock_client.start.assert_called_once()
-class TestGitHubCopilotAgentRunStream:
- """Test cases for run_stream method."""
+class TestGitHubCopilotAgentRunStreaming:
+ """Test cases for run(stream=True) method."""
- async def test_run_stream_basic(
+ async def test_run_streaming_basic(
self,
mock_client: MagicMock,
mock_session: MagicMock,
@@ -384,7 +384,7 @@ def mock_on(handler: Any) -> Any:
agent = GitHubCopilotAgent(client=mock_client)
responses: list[AgentResponseUpdate] = []
- async for update in agent.run_stream("Hello"):
+ async for update in agent.run("Hello", stream=True):
responses.append(update)
assert len(responses) == 1
@@ -392,7 +392,7 @@ def mock_on(handler: Any) -> Any:
assert responses[0].role == "assistant"
assert responses[0].contents[0].text == "Hello"
- async def test_run_stream_with_thread(
+ async def test_run_streaming_with_thread(
self,
mock_client: MagicMock,
mock_session: MagicMock,
@@ -409,12 +409,12 @@ def mock_on(handler: Any) -> Any:
agent = GitHubCopilotAgent(client=mock_client)
thread = AgentThread()
- async for _ in agent.run_stream("Hello", thread=thread):
+ async for _ in agent.run("Hello", thread=thread, stream=True):
pass
assert thread.service_thread_id == mock_session.session_id
- async def test_run_stream_error(
+ async def test_run_streaming_error(
self,
mock_client: MagicMock,
mock_session: MagicMock,
@@ -431,16 +431,16 @@ def mock_on(handler: Any) -> Any:
agent = GitHubCopilotAgent(client=mock_client)
with pytest.raises(ServiceException, match="session error"):
- async for _ in agent.run_stream("Hello"):
+ async for _ in agent.run("Hello", stream=True):
pass
- async def test_run_stream_auto_starts(
+ async def test_run_streaming_auto_starts(
self,
mock_client: MagicMock,
mock_session: MagicMock,
session_idle_event: SessionEvent,
) -> None:
- """Test that run_stream auto-starts the agent if not started."""
+ """Test that run(stream=True) auto-starts the agent if not started."""
def mock_on(handler: Any) -> Any:
handler(session_idle_event)
@@ -451,7 +451,7 @@ def mock_on(handler: Any) -> Any:
agent = GitHubCopilotAgent(client=mock_client)
assert agent._started is False # type: ignore
- async for _ in agent.run_stream("Hello"):
+ async for _ in agent.run("Hello", stream=True):
pass
assert agent._started is True # type: ignore
diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml
index 86cee50527..22eb969bd1 100644
--- a/python/packages/lab/pyproject.toml
+++ b/python/packages/lab/pyproject.toml
@@ -60,12 +60,6 @@ dev = [
"pre-commit >= 3.7",
"ruff>=0.11.8",
"pytest>=8.4.1",
- "pytest-asyncio>=1.0.0",
- "pytest-cov>=6.2.1",
- "pytest-env>=1.1.5",
- "pytest-xdist[psutil]>=3.8.0",
- "pytest-timeout>=2.3.1",
- "pytest-retry>=1",
"mypy>=1.16.1",
"pyright>=1.1.402",
#tasks
diff --git a/python/packages/lab/tau2/agent_framework_lab_tau2/_message_utils.py b/python/packages/lab/tau2/agent_framework_lab_tau2/_message_utils.py
index 4fd5e21fb7..dccf6e2882 100644
--- a/python/packages/lab/tau2/agent_framework_lab_tau2/_message_utils.py
+++ b/python/packages/lab/tau2/agent_framework_lab_tau2/_message_utils.py
@@ -1,9 +1,16 @@
# Copyright (c) Microsoft. All rights reserved.
+from typing import Any
+
from agent_framework._types import ChatMessage, Content
from loguru import logger
+def _get_role_value(role: Any) -> str:
+ """Get the string value of a role, handling both enum and string."""
+ return role.value if hasattr(role, "value") else str(role)
+
+
def flip_messages(messages: list[ChatMessage]) -> list[ChatMessage]:
"""Flip message roles between assistant and user for role-playing scenarios.
@@ -18,7 +25,8 @@ def filter_out_function_calls(messages: list[Content]) -> list[Content]:
flipped_messages = []
for msg in messages:
- if msg.role == "assistant":
+ role_value = _get_role_value(msg.role)
+ if role_value == "assistant":
# Flip assistant to user
contents = filter_out_function_calls(msg.contents)
if contents:
@@ -30,13 +38,13 @@ def filter_out_function_calls(messages: list[Content]) -> list[Content]:
message_id=msg.message_id,
)
flipped_messages.append(flipped_msg)
- elif msg.role == "user":
+ elif role_value == "user":
# Flip user to assistant
flipped_msg = ChatMessage(
role="assistant", contents=msg.contents, author_name=msg.author_name, message_id=msg.message_id
)
flipped_messages.append(flipped_msg)
- elif msg.role == "tool":
+ elif role_value == "tool":
# Skip tool messages
pass
else:
@@ -53,22 +61,23 @@ def log_messages(messages: list[ChatMessage]) -> None:
"""
logger_ = logger.opt(colors=True)
for msg in messages:
+ role_value = _get_role_value(msg.role)
# Handle different content types
if hasattr(msg, "contents") and msg.contents:
for content in msg.contents:
if hasattr(content, "type"):
if content.type == "text":
escape_text = content.text.replace("<", r"\<") # type: ignore[union-attr]
- if msg.role == "system":
+ if role_value == "system":
logger_.info(f"[SYSTEM] {escape_text}")
- elif msg.role == "user":
+ elif role_value == "user":
logger_.info(f"[USER] {escape_text}")
- elif msg.role == "assistant":
+ elif role_value == "assistant":
logger_.info(f"[ASSISTANT] {escape_text}")
- elif msg.role == "tool":
+ elif role_value == "tool":
logger_.info(f"[TOOL] {escape_text}")
else:
- logger_.info(f"[{msg.role.upper()}] {escape_text}")
+ logger_.info(f"[{role_value.upper()}] {escape_text}")
elif content.type == "function_call":
function_call_text = f"{content.name}({content.arguments})"
function_call_text = function_call_text.replace("<", r"\<")
@@ -79,34 +88,34 @@ def log_messages(messages: list[ChatMessage]) -> None:
logger_.info(f"[TOOL_RESULT] 🔨 {function_result_text}")
else:
content_text = str(content).replace("<", r"\<")
- logger_.info(f"[{msg.role.upper()}] ({content.type}) {content_text}")
+ logger_.info(f"[{role_value.upper()}] ({content.type}) {content_text}")
else:
# Fallback for content without type
text_content = str(content).replace("<", r"\<")
- if msg.role == "system":
+ if role_value == "system":
logger_.info(f"[SYSTEM] {text_content}")
- elif msg.role == "user":
+ elif role_value == "user":
logger_.info(f"[USER] {text_content}")
- elif msg.role == "assistant":
+ elif role_value == "assistant":
logger_.info(f"[ASSISTANT] {text_content}")
- elif msg.role == "tool":
+ elif role_value == "tool":
logger_.info(f"[TOOL] {text_content}")
else:
- logger_.info(f"[{msg.role.upper()}] {text_content}")
+ logger_.info(f"[{role_value.upper()}] {text_content}")
elif hasattr(msg, "text") and msg.text:
# Handle simple text messages
text_content = msg.text.replace("<", r"\<")
- if msg.role == "system":
+ if role_value == "system":
logger_.info(f"[SYSTEM] {text_content}")
- elif msg.role == "user":
+ elif role_value == "user":
logger_.info(f"[USER] {text_content}")
- elif msg.role == "assistant":
+ elif role_value == "assistant":
logger_.info(f"[ASSISTANT] {text_content}")
- elif msg.role == "tool":
+ elif role_value == "tool":
logger_.info(f"[TOOL] {text_content}")
else:
- logger_.info(f"[{msg.role.upper()}] {text_content}")
+ logger_.info(f"[{role_value.upper()}] {text_content}")
else:
# Fallback for other message formats
text_content = str(msg).replace("<", r"\<")
- logger_.info(f"[{msg.role.upper()}] {text_content}")
+ logger_.info(f"[{role_value.upper()}] {text_content}")
diff --git a/python/packages/lab/tau2/agent_framework_lab_tau2/_sliding_window.py b/python/packages/lab/tau2/agent_framework_lab_tau2/_sliding_window.py
index cec984272f..20a3a2fe27 100644
--- a/python/packages/lab/tau2/agent_framework_lab_tau2/_sliding_window.py
+++ b/python/packages/lab/tau2/agent_framework_lab_tau2/_sliding_window.py
@@ -51,7 +51,9 @@ def truncate_messages(self) -> None:
logger.warning("Messages exceed max tokens. Truncating oldest message.")
self.truncated_messages.pop(0)
# Remove leading tool messages
- while len(self.truncated_messages) > 0 and self.truncated_messages[0].role == "tool":
+ while len(self.truncated_messages) > 0:
+ if self.truncated_messages[0].role != "tool":
+ break
logger.warning("Removing leading tool message because tool result cannot be the first message.")
self.truncated_messages.pop(0)
diff --git a/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py b/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py
index 0e63f4085e..4822835316 100644
--- a/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py
+++ b/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py
@@ -338,11 +338,11 @@ async def run(
# Matches tau2's expected conversation start pattern
logger.info(f"Starting workflow with hardcoded greeting: '{DEFAULT_FIRST_AGENT_MESSAGE}'")
- first_message = ChatMessage("assistant", text=DEFAULT_FIRST_AGENT_MESSAGE)
+ first_message = ChatMessage(role="assistant", text=DEFAULT_FIRST_AGENT_MESSAGE)
initial_greeting = AgentExecutorResponse(
executor_id=ASSISTANT_AGENT_ID,
agent_response=AgentResponse(messages=[first_message]),
- full_conversation=[ChatMessage("assistant", text=DEFAULT_FIRST_AGENT_MESSAGE)],
+ full_conversation=[ChatMessage(role="assistant", text=DEFAULT_FIRST_AGENT_MESSAGE)],
)
# STEP 4: Execute the workflow and collect results
diff --git a/python/packages/lab/tau2/tests/test_message_utils.py b/python/packages/lab/tau2/tests/test_message_utils.py
index 33b705db3a..7bee8bc9be 100644
--- a/python/packages/lab/tau2/tests/test_message_utils.py
+++ b/python/packages/lab/tau2/tests/test_message_utils.py
@@ -78,7 +78,7 @@ def test_flip_messages_assistant_with_only_function_calls_skipped():
function_call = Content.from_function_call(call_id="call_456", name="another_function", arguments={"key": "value"})
messages = [
- ChatMessage("assistant", [function_call], message_id="msg_004") # Only function call, no text
+ ChatMessage(role="assistant", contents=[function_call], message_id="msg_004") # Only function call, no text
]
flipped = flip_messages(messages)
@@ -91,7 +91,7 @@ def test_flip_messages_tool_messages_skipped():
"""Test that tool messages are skipped."""
function_result = Content.from_function_result(call_id="call_789", result={"success": True})
- messages = [ChatMessage("tool", [function_result])]
+ messages = [ChatMessage(role="tool", contents=[function_result])]
flipped = flip_messages(messages)
@@ -101,7 +101,9 @@ def test_flip_messages_tool_messages_skipped():
def test_flip_messages_system_messages_preserved():
"""Test that system messages are preserved as-is."""
- messages = [ChatMessage("system", [Content.from_text(text="System instruction")], message_id="sys_001")]
+ messages = [
+ ChatMessage(role="system", contents=[Content.from_text(text="System instruction")], message_id="sys_001")
+ ]
flipped = flip_messages(messages)
@@ -118,11 +120,11 @@ def test_flip_messages_mixed_conversation():
function_result = Content.from_function_result(call_id="call_mixed", result="function result")
messages = [
- ChatMessage("system", [Content.from_text(text="System prompt")]),
- ChatMessage("user", [Content.from_text(text="User question")]),
- ChatMessage("assistant", [Content.from_text(text="Assistant response"), function_call]),
- ChatMessage("tool", [function_result]),
- ChatMessage("assistant", [Content.from_text(text="Final response")]),
+ ChatMessage(role="system", contents=[Content.from_text(text="System prompt")]),
+ ChatMessage(role="user", contents=[Content.from_text(text="User question")]),
+ ChatMessage(role="assistant", contents=[Content.from_text(text="Assistant response"), function_call]),
+ ChatMessage(role="tool", contents=[function_result]),
+ ChatMessage(role="assistant", contents=[Content.from_text(text="Final response")]),
]
flipped = flip_messages(messages)
@@ -176,8 +178,8 @@ def test_flip_messages_preserves_metadata():
def test_log_messages_text_content(mock_logger):
"""Test logging messages with text content."""
messages = [
- ChatMessage("user", [Content.from_text(text="Hello")]),
- ChatMessage("assistant", [Content.from_text(text="Hi there!")]),
+ ChatMessage(role="user", contents=[Content.from_text(text="Hello")]),
+ ChatMessage(role="assistant", contents=[Content.from_text(text="Hi there!")]),
]
log_messages(messages)
@@ -191,7 +193,7 @@ def test_log_messages_function_call(mock_logger):
"""Test logging messages with function calls."""
function_call = Content.from_function_call(call_id="call_log", name="log_function", arguments={"param": "value"})
- messages = [ChatMessage("assistant", [function_call])]
+ messages = [ChatMessage(role="assistant", contents=[function_call])]
log_messages(messages)
@@ -207,7 +209,7 @@ def test_log_messages_function_result(mock_logger):
"""Test logging messages with function results."""
function_result = Content.from_function_result(call_id="call_result", result="success")
- messages = [ChatMessage("tool", [function_result])]
+ messages = [ChatMessage(role="tool", contents=[function_result])]
log_messages(messages)
@@ -221,10 +223,10 @@ def test_log_messages_function_result(mock_logger):
def test_log_messages_different_roles(mock_logger):
"""Test logging messages with different roles get different colors."""
messages = [
- ChatMessage("system", [Content.from_text(text="System")]),
- ChatMessage("user", [Content.from_text(text="User")]),
- ChatMessage("assistant", [Content.from_text(text="Assistant")]),
- ChatMessage("tool", [Content.from_text(text="Tool")]),
+ ChatMessage(role="system", contents=[Content.from_text(text="System")]),
+ ChatMessage(role="user", contents=[Content.from_text(text="User")]),
+ ChatMessage(role="assistant", contents=[Content.from_text(text="Assistant")]),
+ ChatMessage(role="tool", contents=[Content.from_text(text="Tool")]),
]
log_messages(messages)
@@ -248,7 +250,7 @@ def test_log_messages_different_roles(mock_logger):
@patch("agent_framework_lab_tau2._message_utils.logger")
def test_log_messages_escapes_html(mock_logger):
"""Test that HTML-like characters are properly escaped in log output."""
- messages = [ChatMessage("user", [Content.from_text(text="Message with content")])]
+ messages = [ChatMessage(role="user", contents=[Content.from_text(text="Message with content")])]
log_messages(messages)
diff --git a/python/packages/lab/tau2/tests/test_sliding_window.py b/python/packages/lab/tau2/tests/test_sliding_window.py
index 971a391882..706bbf75c9 100644
--- a/python/packages/lab/tau2/tests/test_sliding_window.py
+++ b/python/packages/lab/tau2/tests/test_sliding_window.py
@@ -36,8 +36,8 @@ def test_initialization_with_parameters():
def test_initialization_with_messages():
"""Test initializing with existing messages."""
messages = [
- ChatMessage("user", [Content.from_text(text="Hello")]),
- ChatMessage("assistant", [Content.from_text(text="Hi there!")]),
+ ChatMessage(role="user", contents=[Content.from_text(text="Hello")]),
+ ChatMessage(role="assistant", contents=[Content.from_text(text="Hi there!")]),
]
sliding_window = SlidingWindowChatMessageStore(messages=messages, max_tokens=1000)
@@ -51,8 +51,8 @@ async def test_add_messages_simple():
sliding_window = SlidingWindowChatMessageStore(max_tokens=10000) # Large limit
new_messages = [
- ChatMessage("user", [Content.from_text(text="What's the weather?")]),
- ChatMessage("assistant", [Content.from_text(text="I can help with that.")]),
+ ChatMessage(role="user", contents=[Content.from_text(text="What's the weather?")]),
+ ChatMessage(role="assistant", contents=[Content.from_text(text="I can help with that.")]),
]
await sliding_window.add_messages(new_messages)
@@ -68,7 +68,9 @@ async def test_list_all_messages_vs_list_messages():
sliding_window = SlidingWindowChatMessageStore(max_tokens=50) # Small limit to force truncation
# Add many messages to trigger truncation
- messages = [ChatMessage("user", [Content.from_text(text=f"Message {i} with some content")]) for i in range(10)]
+ messages = [
+ ChatMessage(role="user", contents=[Content.from_text(text=f"Message {i} with some content")]) for i in range(10)
+ ]
await sliding_window.add_messages(messages)
@@ -85,7 +87,7 @@ async def test_list_all_messages_vs_list_messages():
def test_get_token_count_basic():
"""Test basic token counting."""
sliding_window = SlidingWindowChatMessageStore(max_tokens=1000)
- sliding_window.truncated_messages = [ChatMessage("user", [Content.from_text(text="Hello")])]
+ sliding_window.truncated_messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])]
token_count = sliding_window.get_token_count()
@@ -102,7 +104,7 @@ def test_get_token_count_with_system_message():
token_count_empty = sliding_window.get_token_count()
# Add a message
- sliding_window.truncated_messages = [ChatMessage("user", [Content.from_text(text="Hello")])]
+ sliding_window.truncated_messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])]
token_count_with_message = sliding_window.get_token_count()
# With message should be more tokens
@@ -115,7 +117,7 @@ def test_get_token_count_function_call():
function_call = Content.from_function_call(call_id="call_123", name="test_function", arguments={"param": "value"})
sliding_window = SlidingWindowChatMessageStore(max_tokens=1000)
- sliding_window.truncated_messages = [ChatMessage("assistant", [function_call])]
+ sliding_window.truncated_messages = [ChatMessage(role="assistant", contents=[function_call])]
token_count = sliding_window.get_token_count()
assert token_count > 0
@@ -126,7 +128,7 @@ def test_get_token_count_function_result():
function_result = Content.from_function_result(call_id="call_123", result={"success": True, "data": "result"})
sliding_window = SlidingWindowChatMessageStore(max_tokens=1000)
- sliding_window.truncated_messages = [ChatMessage("tool", [function_result])]
+ sliding_window.truncated_messages = [ChatMessage(role="tool", contents=[function_result])]
token_count = sliding_window.get_token_count()
assert token_count > 0
@@ -149,7 +151,7 @@ def test_truncate_messages_removes_old_messages(mock_logger):
Content.from_text(text="This is another very long message that should also exceed the token limit")
],
),
- ChatMessage("user", [Content.from_text(text="Short msg")]),
+ ChatMessage(role="user", contents=[Content.from_text(text="Short msg")]),
]
sliding_window.truncated_messages = messages.copy()
@@ -171,7 +173,7 @@ def test_truncate_messages_removes_leading_tool_messages(mock_logger):
tool_message = ChatMessage(
role="tool", contents=[Content.from_function_result(call_id="call_123", result="result")]
)
- user_message = ChatMessage("user", [Content.from_text(text="Hello")])
+ user_message = ChatMessage(role="user", contents=[Content.from_text(text="Hello")])
sliding_window.truncated_messages = [tool_message, user_message]
sliding_window.truncate_messages()
@@ -229,12 +231,12 @@ async def test_real_world_scenario():
# Simulate a conversation
conversation = [
- ChatMessage("user", [Content.from_text(text="Hello, how are you?")]),
+ ChatMessage(role="user", contents=[Content.from_text(text="Hello, how are you?")]),
ChatMessage(
role="assistant",
contents=[Content.from_text(text="I'm doing well, thank you! How can I help you today?")],
),
- ChatMessage("user", [Content.from_text(text="Can you tell me about the weather?")]),
+ ChatMessage(role="user", contents=[Content.from_text(text="Can you tell me about the weather?")]),
ChatMessage(
role="assistant",
contents=[
@@ -244,7 +246,7 @@ async def test_real_world_scenario():
)
],
),
- ChatMessage("user", [Content.from_text(text="What about telling me a joke instead?")]),
+ ChatMessage(role="user", contents=[Content.from_text(text="What about telling me a joke instead?")]),
ChatMessage(
role="assistant",
contents=[
diff --git a/python/packages/lab/tau2/tests/test_tau2_utils.py b/python/packages/lab/tau2/tests/test_tau2_utils.py
index 29520bda42..dff8a56e5c 100644
--- a/python/packages/lab/tau2/tests/test_tau2_utils.py
+++ b/python/packages/lab/tau2/tests/test_tau2_utils.py
@@ -91,7 +91,7 @@ def test_convert_tau2_tool_to_function_tool_multiple_tools(tau2_airline_environm
def test_convert_agent_framework_messages_to_tau2_messages_system():
"""Test converting system message."""
- messages = [ChatMessage("system", [Content.from_text(text="System instruction")])]
+ messages = [ChatMessage(role="system", contents=[Content.from_text(text="System instruction")])]
tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages)
@@ -103,7 +103,7 @@ def test_convert_agent_framework_messages_to_tau2_messages_system():
def test_convert_agent_framework_messages_to_tau2_messages_user():
"""Test converting user message."""
- messages = [ChatMessage("user", [Content.from_text(text="Hello assistant")])]
+ messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello assistant")])]
tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages)
@@ -116,7 +116,7 @@ def test_convert_agent_framework_messages_to_tau2_messages_user():
def test_convert_agent_framework_messages_to_tau2_messages_assistant():
"""Test converting assistant message."""
- messages = [ChatMessage("assistant", [Content.from_text(text="Hello user")])]
+ messages = [ChatMessage(role="assistant", contents=[Content.from_text(text="Hello user")])]
tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages)
@@ -131,7 +131,7 @@ def test_convert_agent_framework_messages_to_tau2_messages_with_function_call():
"""Test converting message with function call."""
function_call = Content.from_function_call(call_id="call_123", name="test_function", arguments={"param": "value"})
- messages = [ChatMessage("assistant", [Content.from_text(text="I'll call a function"), function_call])]
+ messages = [ChatMessage(role="assistant", contents=[Content.from_text(text="I'll call a function"), function_call])]
tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages)
@@ -153,7 +153,7 @@ def test_convert_agent_framework_messages_to_tau2_messages_with_function_result(
"""Test converting message with function result."""
function_result = Content.from_function_result(call_id="call_123", result={"success": True, "data": "result data"})
- messages = [ChatMessage("tool", [function_result])]
+ messages = [ChatMessage(role="tool", contents=[function_result])]
tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages)
@@ -173,7 +173,7 @@ def test_convert_agent_framework_messages_to_tau2_messages_with_error():
call_id="call_456", result="Error occurred", exception=Exception("Test error")
)
- messages = [ChatMessage("tool", [function_result])]
+ messages = [ChatMessage(role="tool", contents=[function_result])]
tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages)
@@ -184,7 +184,9 @@ def test_convert_agent_framework_messages_to_tau2_messages_with_error():
def test_convert_agent_framework_messages_to_tau2_messages_multiple_text_contents():
"""Test converting message with multiple text contents."""
- messages = [ChatMessage("user", [Content.from_text(text="First part"), Content.from_text(text="Second part")])]
+ messages = [
+ ChatMessage(role="user", contents=[Content.from_text(text="First part"), Content.from_text(text="Second part")])
+ ]
tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages)
@@ -200,11 +202,11 @@ def test_convert_agent_framework_messages_to_tau2_messages_complex_scenario():
function_result = Content.from_function_result(call_id="call_789", result={"output": "tool result"})
messages = [
- ChatMessage("system", [Content.from_text(text="System prompt")]),
- ChatMessage("user", [Content.from_text(text="User request")]),
- ChatMessage("assistant", [Content.from_text(text="I'll help you"), function_call]),
- ChatMessage("tool", [function_result]),
- ChatMessage("assistant", [Content.from_text(text="Based on the result...")]),
+ ChatMessage(role="system", contents=[Content.from_text(text="System prompt")]),
+ ChatMessage(role="user", contents=[Content.from_text(text="User request")]),
+ ChatMessage(role="assistant", contents=[Content.from_text(text="I'll help you"), function_call]),
+ ChatMessage(role="tool", contents=[function_result]),
+ ChatMessage(role="assistant", contents=[Content.from_text(text="Based on the result...")]),
]
tau2_messages = convert_agent_framework_messages_to_tau2_messages(messages)
diff --git a/python/packages/mem0/agent_framework_mem0/_provider.py b/python/packages/mem0/agent_framework_mem0/_provider.py
index ac37cc1a2c..0d12f06e5f 100644
--- a/python/packages/mem0/agent_framework_mem0/_provider.py
+++ b/python/packages/mem0/agent_framework_mem0/_provider.py
@@ -120,10 +120,14 @@ async def invoked(
)
messages_list = [*request_messages_list, *response_messages_list]
+ # Extract role value - it may be a Role enum or a string
+ def get_role_value(role: Any) -> str:
+ return role.value if hasattr(role, "value") else str(role)
+
messages: list[dict[str, str]] = [
- {"role": message.role, "content": message.text}
+ {"role": get_role_value(message.role), "content": message.text}
for message in messages_list
- if message.role in {"user", "assistant", "system"} and message.text and message.text.strip()
+ if get_role_value(message.role) in {"user", "assistant", "system"} and message.text and message.text.strip()
]
if messages:
@@ -176,7 +180,7 @@ async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], *
line_separated_memories = "\n".join(memory.get("memory", "") for memory in memories)
return Context(
- messages=[ChatMessage("user", [f"{self.context_prompt}\n{line_separated_memories}"])]
+ messages=[ChatMessage(role="user", text=f"{self.context_prompt}\n{line_separated_memories}")]
if line_separated_memories
else None
)
diff --git a/python/packages/mem0/tests/test_mem0_context_provider.py b/python/packages/mem0/tests/test_mem0_context_provider.py
index 0b39c7b043..432468fe3f 100644
--- a/python/packages/mem0/tests/test_mem0_context_provider.py
+++ b/python/packages/mem0/tests/test_mem0_context_provider.py
@@ -4,7 +4,7 @@
import importlib
import os
import sys
-from unittest.mock import AsyncMock, patch
+from unittest.mock import AsyncMock
import pytest
from agent_framework import ChatMessage, Content, Context
@@ -36,109 +36,75 @@ def mock_mem0_client() -> AsyncMock:
def sample_messages() -> list[ChatMessage]:
"""Create sample chat messages for testing."""
return [
- ChatMessage("user", ["Hello, how are you?"]),
- ChatMessage("assistant", ["I'm doing well, thank you!"]),
- ChatMessage("system", ["You are a helpful assistant"]),
+ ChatMessage(role="user", text="Hello, how are you?"),
+ ChatMessage(role="assistant", text="I'm doing well, thank you!"),
+ ChatMessage(role="system", text="You are a helpful assistant"),
]
-class TestMem0ProviderInitialization:
- """Test initialization and configuration of Mem0Provider."""
+def test_init_with_all_ids(mock_mem0_client: AsyncMock) -> None:
+ """Test initialization with all IDs provided."""
+ provider = Mem0Provider(
+ user_id="user123",
+ agent_id="agent123",
+ application_id="app123",
+ thread_id="thread123",
+ mem0_client=mock_mem0_client,
+ )
+ assert provider.user_id == "user123"
+ assert provider.agent_id == "agent123"
+ assert provider.application_id == "app123"
+ assert provider.thread_id == "thread123"
- def test_init_with_all_ids(self, mock_mem0_client: AsyncMock) -> None:
- """Test initialization with all IDs provided."""
- provider = Mem0Provider(
- user_id="user123",
- agent_id="agent123",
- application_id="app123",
- thread_id="thread123",
- mem0_client=mock_mem0_client,
- )
- assert provider.user_id == "user123"
- assert provider.agent_id == "agent123"
- assert provider.application_id == "app123"
- assert provider.thread_id == "thread123"
- def test_init_without_filters_succeeds(self, mock_mem0_client: AsyncMock) -> None:
- """Test that initialization succeeds even without filters (validation happens during invocation)."""
- provider = Mem0Provider(mem0_client=mock_mem0_client)
- assert provider.user_id is None
- assert provider.agent_id is None
- assert provider.application_id is None
- assert provider.thread_id is None
-
- def test_init_with_custom_context_prompt(self, mock_mem0_client: AsyncMock) -> None:
- """Test initialization with custom context prompt."""
- custom_prompt = "## Custom Memories\nConsider these memories:"
- provider = Mem0Provider(user_id="user123", context_prompt=custom_prompt, mem0_client=mock_mem0_client)
- assert provider.context_prompt == custom_prompt
-
- def test_init_with_scope_to_per_operation_thread_id(self, mock_mem0_client: AsyncMock) -> None:
- """Test initialization with scope_to_per_operation_thread_id enabled."""
- provider = Mem0Provider(
- user_id="user123",
- scope_to_per_operation_thread_id=True,
- mem0_client=mock_mem0_client,
- )
- assert provider.scope_to_per_operation_thread_id is True
-
- @patch("agent_framework_mem0._provider.AsyncMemoryClient")
- def test_init_creates_default_client_when_none_provided(self, mock_memory_client_class: AsyncMock) -> None:
- """Test that a default client is created when none is provided."""
- from mem0 import AsyncMemoryClient
-
- mock_client = AsyncMock(spec=AsyncMemoryClient)
- mock_memory_client_class.return_value = mock_client
-
- provider = Mem0Provider(user_id="user123", api_key="test_api_key")
+def test_init_without_filters_succeeds(mock_mem0_client: AsyncMock) -> None:
+ """Test that initialization succeeds even without filters (validation happens during invocation)."""
+ provider = Mem0Provider(mem0_client=mock_mem0_client)
+ assert provider.user_id is None
+ assert provider.agent_id is None
+ assert provider.application_id is None
+ assert provider.thread_id is None
- mock_memory_client_class.assert_called_once_with(api_key="test_api_key")
- assert provider.mem0_client == mock_client
- assert provider._should_close_client is True
-
- def test_init_with_provided_client_should_not_close(self, mock_mem0_client: AsyncMock) -> None:
- """Test that provided client should not be closed by provider."""
- provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client)
- assert provider._should_close_client is False
+def test_init_with_custom_context_prompt(mock_mem0_client: AsyncMock) -> None:
+ """Test initialization with custom context prompt."""
+ custom_prompt = "## Custom Memories\nConsider these memories:"
+ provider = Mem0Provider(user_id="user123", context_prompt=custom_prompt, mem0_client=mock_mem0_client)
+ assert provider.context_prompt == custom_prompt
-class TestMem0ProviderAsyncContextManager:
- """Test async context manager behavior."""
- async def test_async_context_manager_entry(self, mock_mem0_client: AsyncMock) -> None:
- """Test async context manager entry returns self."""
- provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client)
- async with provider as ctx:
- assert ctx is provider
+def test_init_with_scope_to_per_operation_thread_id(mock_mem0_client: AsyncMock) -> None:
+ """Test initialization with scope_to_per_operation_thread_id enabled."""
+ provider = Mem0Provider(
+ user_id="user123",
+ scope_to_per_operation_thread_id=True,
+ mem0_client=mock_mem0_client,
+ )
+ assert provider.scope_to_per_operation_thread_id is True
- async def test_async_context_manager_exit_closes_client_when_should_close(self) -> None:
- """Test that async context manager closes client when it should."""
- from mem0 import AsyncMemoryClient
- mock_client = AsyncMock(spec=AsyncMemoryClient)
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock()
- mock_client.async_client = AsyncMock()
- mock_client.async_client.aclose = AsyncMock()
+def test_init_with_provided_client_should_not_close(mock_mem0_client: AsyncMock) -> None:
+ """Test that provided client should not be closed by provider."""
+ provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client)
+ assert provider._should_close_client is False
- with patch("agent_framework_mem0._provider.AsyncMemoryClient", return_value=mock_client):
- provider = Mem0Provider(user_id="user123", api_key="test_key")
- assert provider._should_close_client is True
- async with provider:
- pass
+async def test_async_context_manager_entry(mock_mem0_client: AsyncMock) -> None:
+ """Test async context manager entry returns self."""
+ provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client)
+ async with provider as ctx:
+ assert ctx is provider
- mock_client.__aexit__.assert_called_once()
- async def test_async_context_manager_exit_does_not_close_provided_client(self, mock_mem0_client: AsyncMock) -> None:
- """Test that async context manager does not close provided client."""
- provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client)
- assert provider._should_close_client is False
+async def test_async_context_manager_exit_does_not_close_provided_client(mock_mem0_client: AsyncMock) -> None:
+ """Test that async context manager does not close provided client."""
+ provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client)
+ assert provider._should_close_client is False
- async with provider:
- pass
+ async with provider:
+ pass
- mock_mem0_client.__aexit__.assert_not_called()
+ mock_mem0_client.__aexit__.assert_not_called()
class TestMem0ProviderThreadMethods:
@@ -191,7 +157,7 @@ class TestMem0ProviderMessagesAdding:
async def test_messages_adding_fails_without_filters(self, mock_mem0_client: AsyncMock) -> None:
"""Test that invoked fails when no filters are provided."""
provider = Mem0Provider(mem0_client=mock_mem0_client)
- message = ChatMessage("user", ["Hello!"])
+ message = ChatMessage(role="user", text="Hello!")
with pytest.raises(ServiceInitializationError) as exc_info:
await provider.invoked(message)
@@ -201,7 +167,7 @@ async def test_messages_adding_fails_without_filters(self, mock_mem0_client: Asy
async def test_messages_adding_single_message(self, mock_mem0_client: AsyncMock) -> None:
"""Test adding a single message."""
provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client)
- message = ChatMessage("user", ["Hello!"])
+ message = ChatMessage(role="user", text="Hello!")
await provider.invoked(message)
@@ -288,9 +254,9 @@ async def test_messages_adding_filters_empty_messages(self, mock_mem0_client: As
"""Test that empty or invalid messages are filtered out."""
provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client)
messages = [
- ChatMessage("user", [""]), # Empty text
- ChatMessage("user", [" "]), # Whitespace only
- ChatMessage("user", ["Valid message"]),
+ ChatMessage(role="user", text=""), # Empty text
+ ChatMessage(role="user", text=" "), # Whitespace only
+ ChatMessage(role="user", text="Valid message"),
]
await provider.invoked(messages)
@@ -303,8 +269,8 @@ async def test_messages_adding_skips_when_no_valid_messages(self, mock_mem0_clie
"""Test that mem0 client is not called when no valid messages exist."""
provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client)
messages = [
- ChatMessage("user", [""]),
- ChatMessage("user", [" "]),
+ ChatMessage(role="user", text=""),
+ ChatMessage(role="user", text=" "),
]
await provider.invoked(messages)
@@ -318,7 +284,7 @@ class TestMem0ProviderModelInvoking:
async def test_model_invoking_fails_without_filters(self, mock_mem0_client: AsyncMock) -> None:
"""Test that invoking fails when no filters are provided."""
provider = Mem0Provider(mem0_client=mock_mem0_client)
- message = ChatMessage("user", ["What's the weather?"])
+ message = ChatMessage(role="user", text="What's the weather?")
with pytest.raises(ServiceInitializationError) as exc_info:
await provider.invoking(message)
@@ -328,7 +294,7 @@ async def test_model_invoking_fails_without_filters(self, mock_mem0_client: Asyn
async def test_model_invoking_single_message(self, mock_mem0_client: AsyncMock) -> None:
"""Test invoking with a single message."""
provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client)
- message = ChatMessage("user", ["What's the weather?"])
+ message = ChatMessage(role="user", text="What's the weather?")
# Mock search results
mock_mem0_client.search.return_value = [
@@ -369,7 +335,7 @@ async def test_model_invoking_multiple_messages(
async def test_model_invoking_with_agent_id(self, mock_mem0_client: AsyncMock) -> None:
"""Test invoking with agent_id."""
provider = Mem0Provider(agent_id="agent123", mem0_client=mock_mem0_client)
- message = ChatMessage("user", ["Hello"])
+ message = ChatMessage(role="user", text="Hello")
mock_mem0_client.search.return_value = []
@@ -387,7 +353,7 @@ async def test_model_invoking_with_scope_to_per_operation_thread_id(self, mock_m
mem0_client=mock_mem0_client,
)
provider._per_operation_thread_id = "operation_thread"
- message = ChatMessage("user", ["Hello"])
+ message = ChatMessage(role="user", text="Hello")
mock_mem0_client.search.return_value = []
@@ -399,7 +365,7 @@ async def test_model_invoking_with_scope_to_per_operation_thread_id(self, mock_m
async def test_model_invoking_no_memories_returns_none_instructions(self, mock_mem0_client: AsyncMock) -> None:
"""Test that no memories returns context with None instructions."""
provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client)
- message = ChatMessage("user", ["Hello"])
+ message = ChatMessage(role="user", text="Hello")
mock_mem0_client.search.return_value = []
@@ -437,9 +403,9 @@ async def test_model_invoking_filters_empty_message_text(self, mock_mem0_client:
"""Test that empty message text is filtered out from query."""
provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client)
messages = [
- ChatMessage("user", [""]),
- ChatMessage("user", ["Valid message"]),
- ChatMessage("user", [" "]),
+ ChatMessage(role="user", text=""),
+ ChatMessage(role="user", text="Valid message"),
+ ChatMessage(role="user", text=" "),
]
mock_mem0_client.search.return_value = []
@@ -457,7 +423,7 @@ async def test_model_invoking_custom_context_prompt(self, mock_mem0_client: Asyn
context_prompt=custom_prompt,
mem0_client=mock_mem0_client,
)
- message = ChatMessage("user", ["Hello"])
+ message = ChatMessage(role="user", text="Hello")
mock_mem0_client.search.return_value = [{"memory": "Test memory"}]
diff --git a/python/packages/ollama/agent_framework_ollama/_chat_client.py b/python/packages/ollama/agent_framework_ollama/_chat_client.py
index 2891ab5bcb..6b4b55faac 100644
--- a/python/packages/ollama/agent_framework_ollama/_chat_client.py
+++ b/python/packages/ollama/agent_framework_ollama/_chat_client.py
@@ -4,28 +4,32 @@
import sys
from collections.abc import (
AsyncIterable,
+ Awaitable,
Callable,
Mapping,
MutableMapping,
- MutableSequence,
Sequence,
)
from itertools import chain
-from typing import Any, ClassVar, Generic
+from typing import Any, ClassVar, Generic, TypedDict
from agent_framework import (
BaseChatClient,
+ ChatAndFunctionMiddlewareTypes,
ChatMessage,
+ ChatMiddlewareLayer,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Content,
+ FunctionInvocationConfiguration,
+ FunctionInvocationLayer,
FunctionTool,
+ HostedWebSearchTool,
+ ResponseStream,
ToolProtocol,
UsageDetails,
get_logger,
- use_chat_middleware,
- use_function_invocation,
)
from agent_framework._pydantic import AFBaseSettings
from agent_framework.exceptions import (
@@ -33,7 +37,7 @@
ServiceInvalidRequestError,
ServiceResponseException,
)
-from agent_framework.observability import use_instrumentation
+from agent_framework.observability import ChatTelemetryLayer
from ollama import AsyncClient
# Rename imported types to avoid naming conflicts with Agent Framework types
@@ -56,6 +60,7 @@
else:
from typing_extensions import TypedDict # type: ignore # pragma: no cover
+
__all__ = ["OllamaChatClient", "OllamaChatOptions"]
TResponseModel = TypeVar("TResponseModel", bound=BaseModel | None, default=None)
@@ -283,11 +288,13 @@ class OllamaSettings(AFBaseSettings):
logger = get_logger("agent_framework.ollama")
-@use_function_invocation
-@use_instrumentation
-@use_chat_middleware
-class OllamaChatClient(BaseChatClient[TOllamaChatOptions], Generic[TOllamaChatOptions]):
- """Ollama Chat completion class."""
+class OllamaChatClient(
+ ChatMiddlewareLayer[TOllamaChatOptions],
+ FunctionInvocationLayer[TOllamaChatOptions],
+ ChatTelemetryLayer[TOllamaChatOptions],
+ BaseChatClient[TOllamaChatOptions],
+):
+ """Ollama Chat completion class with middleware, telemetry, and function invocation support."""
OTEL_PROVIDER_NAME: ClassVar[str] = "ollama"
@@ -297,6 +304,8 @@ def __init__(
host: str | None = None,
client: AsyncClient | None = None,
model_id: str | None = None,
+ middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
+ function_invocation_configuration: FunctionInvocationConfiguration | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
**kwargs: Any,
@@ -308,6 +317,8 @@ def __init__(
Can be set via the OLLAMA_HOST env variable.
client: An optional Ollama Client instance. If not provided, a new instance will be created.
model_id: The Ollama chat model ID to use. Can be set via the OLLAMA_MODEL_ID env variable.
+ middleware: Optional middleware to apply to the client.
+ function_invocation_configuration: Optional function invocation configuration override.
env_file_path: An optional path to a dotenv (.env) file to load environment variables from.
env_file_encoding: The encoding to use when reading the dotenv (.env) file. Defaults to 'utf-8'.
**kwargs: Additional keyword arguments passed to BaseChatClient.
@@ -332,58 +343,59 @@ def __init__(
# Save Host URL for serialization with to_dict()
self.host = str(self.client._client.base_url)
- super().__init__(**kwargs)
-
- @override
- async def _inner_get_response(
- self,
- *,
- messages: MutableSequence[ChatMessage],
- options: dict[str, Any],
- **kwargs: Any,
- ) -> ChatResponse:
- # prepare
- options_dict = self._prepare_options(messages, options)
-
- try:
- # execute
- response: OllamaChatResponse = await self.client.chat( # type: ignore[misc]
- stream=False,
- **options_dict,
- **kwargs,
- )
- except Exception as ex:
- raise ServiceResponseException(f"Ollama chat request failed : {ex}", ex) from ex
-
- # process
- return self._parse_response_from_ollama(response)
+ super().__init__(
+ middleware=middleware,
+ function_invocation_configuration=function_invocation_configuration,
+ **kwargs,
+ )
+ self.middleware = list(self.chat_middleware)
@override
- async def _inner_get_streaming_response(
+ def _inner_get_response(
self,
*,
- messages: MutableSequence[ChatMessage],
- options: dict[str, Any],
+ messages: Sequence[ChatMessage],
+ options: Mapping[str, Any],
+ stream: bool = False,
**kwargs: Any,
- ) -> AsyncIterable[ChatResponseUpdate]:
- # prepare
- options_dict = self._prepare_options(messages, options)
-
- try:
- # execute
- response_object: AsyncIterable[OllamaChatResponse] = await self.client.chat( # type: ignore[misc]
- stream=True,
- **options_dict,
- **kwargs,
- )
- except Exception as ex:
- raise ServiceResponseException(f"Ollama streaming chat request failed : {ex}", ex) from ex
-
- # process
- async for part in response_object:
- yield self._parse_streaming_response_from_ollama(part)
-
- def _prepare_options(self, messages: MutableSequence[ChatMessage], options: dict[str, Any]) -> dict[str, Any]:
+ ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
+ if stream:
+ # Streaming mode
+ async def _stream() -> AsyncIterable[ChatResponseUpdate]:
+ validated_options = await self._validate_options(options)
+ options_dict = self._prepare_options(messages, validated_options)
+ try:
+ response_object: AsyncIterable[OllamaChatResponse] = await self.client.chat( # type: ignore[misc]
+ stream=True,
+ **options_dict,
+ **kwargs,
+ )
+ except Exception as ex:
+ raise ServiceResponseException(f"Ollama streaming chat request failed : {ex}", ex) from ex
+
+ async for part in response_object:
+ yield self._parse_streaming_response_from_ollama(part)
+
+ return self._build_response_stream(_stream(), response_format=options.get("response_format"))
+
+ # Non-streaming mode
+ async def _get_response() -> ChatResponse:
+ validated_options = await self._validate_options(options)
+ options_dict = self._prepare_options(messages, validated_options)
+ try:
+ response: OllamaChatResponse = await self.client.chat( # type: ignore[misc]
+ stream=False,
+ **options_dict,
+ **kwargs,
+ )
+ except Exception as ex:
+ raise ServiceResponseException(f"Ollama chat request failed : {ex}", ex) from ex
+
+ return self._parse_response_from_ollama(response)
+
+ return _get_response()
+
+ def _prepare_options(self, messages: Sequence[ChatMessage], options: Mapping[str, Any]) -> dict[str, Any]:
# Handle instructions by prepending to messages as system message
instructions = options.get("instructions")
if instructions:
@@ -429,12 +441,12 @@ def _prepare_options(self, messages: MutableSequence[ChatMessage], options: dict
# tools
tools = options.get("tools")
- if tools and (prepared_tools := self._prepare_tools_for_ollama(tools)):
+ if tools is not None and (prepared_tools := self._prepare_tools_for_ollama(tools)):
run_options["tools"] = prepared_tools
return run_options
- def _prepare_messages_for_ollama(self, messages: MutableSequence[ChatMessage]) -> list[OllamaMessage]:
+ def _prepare_messages_for_ollama(self, messages: Sequence[ChatMessage]) -> list[OllamaMessage]:
ollama_messages = [self._prepare_message_for_ollama(msg) for msg in messages]
# Flatten the list of lists into a single list
return list(chain.from_iterable(ollama_messages))
@@ -524,7 +536,7 @@ def _parse_response_from_ollama(self, response: OllamaChatResponse) -> ChatRespo
contents = self._parse_contents_from_ollama(response)
return ChatResponse(
- messages=[ChatMessage("assistant", contents)],
+ messages=[ChatMessage(role="assistant", contents=contents)],
model_id=response.model,
created_at=response.created_at,
usage_details=UsageDetails(
@@ -552,6 +564,8 @@ def _prepare_tools_for_ollama(self, tools: list[ToolProtocol | MutableMapping[st
match tool:
case FunctionTool():
chat_tools.append(tool.to_json_schema_spec())
+ case HostedWebSearchTool():
+ raise ServiceInvalidRequestError("HostedWebSearchTool is not supported by the Ollama client.")
case _:
raise ServiceInvalidRequestError(
"Unsupported tool type '"
diff --git a/python/packages/ollama/tests/test_ollama_chat_client.py b/python/packages/ollama/tests/test_ollama_chat_client.py
index 9658ba7c6e..efe6d70890 100644
--- a/python/packages/ollama/tests/test_ollama_chat_client.py
+++ b/python/packages/ollama/tests/test_ollama_chat_client.py
@@ -261,7 +261,7 @@ async def test_cmc_streaming(
chat_history.append(ChatMessage(text="hello world", role="user"))
ollama_client = OllamaChatClient()
- result = ollama_client.get_streaming_response(messages=chat_history)
+ result = ollama_client.get_response(messages=chat_history, stream=True)
async for chunk in result:
assert chunk.text == "test"
@@ -278,7 +278,7 @@ async def test_cmc_streaming_reasoning(
chat_history.append(ChatMessage(text="hello world", role="user"))
ollama_client = OllamaChatClient()
- result = ollama_client.get_streaming_response(messages=chat_history)
+ result = ollama_client.get_response(messages=chat_history, stream=True)
async for chunk in result:
reasoning = "".join(c.text for c in chunk.contents if c.type == "text_reasoning")
@@ -298,7 +298,7 @@ async def test_cmc_streaming_chat_failure(
ollama_client = OllamaChatClient()
with pytest.raises(ServiceResponseException) as exc_info:
- async for _ in ollama_client.get_streaming_response(messages=chat_history):
+ async for _ in ollama_client.get_response(messages=chat_history, stream=True):
pass
assert "Ollama streaming chat request failed" in str(exc_info.value)
@@ -321,7 +321,7 @@ async def test_cmc_streaming_with_tool_call(
chat_history.append(ChatMessage(text="hello world", role="user"))
ollama_client = OllamaChatClient()
- result = ollama_client.get_streaming_response(messages=chat_history, options={"tools": [hello_world]})
+ result = ollama_client.get_response(messages=chat_history, stream=True, options={"tools": [hello_world]})
chunks: list[ChatResponseUpdate] = []
async for chunk in result:
@@ -463,8 +463,8 @@ async def test_cmc_streaming_integration_with_tool_call(
chat_history.append(ChatMessage(text="Call the hello world function and repeat what it says", role="user"))
ollama_client = OllamaChatClient()
- result: AsyncIterable[ChatResponseUpdate] = ollama_client.get_streaming_response(
- messages=chat_history, options={"tools": [hello_world]}
+ result: AsyncIterable[ChatResponseUpdate] = ollama_client.get_response(
+ messages=chat_history, stream=True, options={"tools": [hello_world]}
)
chunks: list[ChatResponseUpdate] = []
@@ -488,7 +488,7 @@ async def test_cmc_streaming_integration_with_chat_completion(
chat_history.append(ChatMessage(text="Say Hello World", role="user"))
ollama_client = OllamaChatClient()
- result: AsyncIterable[ChatResponseUpdate] = ollama_client.get_streaming_response(messages=chat_history)
+ result: AsyncIterable[ChatResponseUpdate] = ollama_client.get_response(messages=chat_history, stream=True)
full_text = ""
async for chunk in result:
diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py
index 5fb5d9db17..ce25ae5c66 100644
--- a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py
+++ b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py
@@ -423,7 +423,7 @@ async def _invoke_agent_helper(conversation: list[ChatMessage]) -> AgentOrchestr
])
)
# Prepend instruction as system message
- current_conversation.append(ChatMessage("user", [instruction]))
+ current_conversation.append(ChatMessage(role="user", text=instruction))
retry_attempts = self._retry_attempts
while True:
diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py
index a26bf1ea37..29bc79e30e 100644
--- a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py
+++ b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py
@@ -141,9 +141,11 @@ async def process(
await next(context)
return
+ from agent_framework._middleware import MiddlewareTermination
+
# Short-circuit execution and provide deterministic response payload for the tool call.
context.result = {HANDOFF_FUNCTION_RESULT_KEY: self._handoff_functions[context.function.name]}
- context.terminate = True
+ raise MiddlewareTermination(result=context.result)
@dataclass
@@ -161,7 +163,7 @@ def create_response(response: str | list[str] | ChatMessage | list[ChatMessage])
"""Create a HandoffAgentUserRequest from a simple text response."""
messages: list[ChatMessage] = []
if isinstance(response, str):
- messages.append(ChatMessage("user", [response]))
+ messages.append(ChatMessage(role="user", text=response))
elif isinstance(response, ChatMessage):
messages.append(response)
elif isinstance(response, list):
@@ -169,7 +171,7 @@ def create_response(response: str | list[str] | ChatMessage | list[ChatMessage])
if isinstance(item, ChatMessage):
messages.append(item)
elif isinstance(item, str):
- messages.append(ChatMessage("user", [item]))
+ messages.append(ChatMessage(role="user", text=item))
else:
raise TypeError("List items must be either str or ChatMessage instances")
else:
@@ -428,7 +430,7 @@ async def _run_agent_and_emit(
# or a termination condition is met.
# This allows the agent to perform long-running tasks without returning control
# to the coordinator or user prematurely.
- self._cache.extend([ChatMessage("user", [self._autonomous_mode_prompt])])
+ self._cache.extend([ChatMessage(role="user", text=self._autonomous_mode_prompt)])
self._autonomous_mode_turns += 1
await self._run_agent_and_emit(ctx)
else:
@@ -975,12 +977,12 @@ def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "HandoffB
workflow = HandoffBuilder(participants=[triage, refund, billing]).with_checkpointing(storage).build()
# Run workflow with a session ID for resumption
- async for event in workflow.run_stream("Help me", session_id="user_123"):
+ async for event in workflow.run("Help me", session_id="user_123", stream=True):
# Process events...
pass
# Later, resume the same conversation
- async for event in workflow.run_stream("I need a refund", session_id="user_123"):
+ async for event in workflow.run("I need a refund", session_id="user_123", stream=True):
# Conversation continues from where it left off
pass
@@ -1039,7 +1041,7 @@ def build(self) -> Workflow:
- Request/response handling
Returns:
- A fully configured Workflow ready to execute via `.run()` or `.run_stream()`.
+ A fully configured Workflow ready to execute via `.run()` with optional `stream=True` parameter.
Raises:
ValueError: If participants or coordinator were not configured, or if
diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py
index 0e2ca703e3..3a013a4acd 100644
--- a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py
+++ b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py
@@ -629,7 +629,7 @@ async def plan(self, magentic_context: MagenticContext) -> ChatMessage:
facts=facts_msg.text,
plan=plan_msg.text,
)
- return ChatMessage("assistant", [combined], author_name=MAGENTIC_MANAGER_NAME)
+ return ChatMessage(role="assistant", text=combined, author_name=MAGENTIC_MANAGER_NAME)
async def replan(self, magentic_context: MagenticContext) -> ChatMessage:
"""Update facts and plan when stalling or looping has been detected."""
@@ -640,19 +640,17 @@ async def replan(self, magentic_context: MagenticContext) -> ChatMessage:
# Update facts
facts_update_user = ChatMessage(
- "user",
- [
- self.task_ledger_facts_update_prompt.format(
- task=magentic_context.task, old_facts=self.task_ledger.facts.text
- )
- ],
+ role="user",
+ text=self.task_ledger_facts_update_prompt.format(
+ task=magentic_context.task, old_facts=self.task_ledger.facts.text
+ ),
)
updated_facts = await self._complete([*magentic_context.chat_history, facts_update_user])
# Update plan
plan_update_user = ChatMessage(
- "user",
- [self.task_ledger_plan_update_prompt.format(team=team_text)],
+ role="user",
+ text=self.task_ledger_plan_update_prompt.format(team=team_text),
)
updated_plan = await self._complete([
*magentic_context.chat_history,
@@ -674,7 +672,7 @@ async def replan(self, magentic_context: MagenticContext) -> ChatMessage:
facts=updated_facts.text,
plan=updated_plan.text,
)
- return ChatMessage("assistant", [combined], author_name=MAGENTIC_MANAGER_NAME)
+ return ChatMessage(role="assistant", text=combined, author_name=MAGENTIC_MANAGER_NAME)
async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger:
"""Use the model to produce a JSON progress ledger based on the conversation so far.
@@ -694,7 +692,7 @@ async def create_progress_ledger(self, magentic_context: MagenticContext) -> Mag
team=team_text,
names=names_csv,
)
- user_message = ChatMessage("user", [prompt])
+ user_message = ChatMessage(role="user", text=prompt)
# Include full context to help the model decide current stage, with small retry loop
attempts = 0
@@ -721,7 +719,7 @@ async def create_progress_ledger(self, magentic_context: MagenticContext) -> Mag
async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage:
"""Ask the model to produce the final answer addressed to the user."""
prompt = self.final_answer_prompt.format(task=magentic_context.task)
- user_message = ChatMessage("user", [prompt])
+ user_message = ChatMessage(role="user", text=prompt)
response = await self._complete([*magentic_context.chat_history, user_message])
# Ensure role is assistant
return ChatMessage(
@@ -811,11 +809,11 @@ def approve() -> "MagenticPlanReviewResponse":
def revise(feedback: str | list[str] | ChatMessage | list[ChatMessage]) -> "MagenticPlanReviewResponse":
"""Create a revision response with feedback."""
if isinstance(feedback, str):
- feedback = [ChatMessage("user", [feedback])]
+ feedback = [ChatMessage(role="user", text=feedback)]
elif isinstance(feedback, ChatMessage):
feedback = [feedback]
elif isinstance(feedback, list):
- feedback = [ChatMessage("user", [item]) if isinstance(item, str) else item for item in feedback]
+ feedback = [ChatMessage(role="user", text=item) if isinstance(item, str) else item for item in feedback]
return MagenticPlanReviewResponse(review=feedback)
@@ -1515,7 +1513,7 @@ def with_plan_review(self, enable: bool = True) -> "MagenticBuilder":
)
# During execution, handle plan review
- async for event in workflow.run_stream("task"):
+ async for event in workflow.run("task", stream=True):
if isinstance(event, RequestInfoEvent):
request = event.data
if isinstance(request, MagenticHumanInterventionRequest):
@@ -1563,11 +1561,11 @@ def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "Magentic
# First run
thread_id = "task-123"
- async for msg in workflow.run("task", thread_id=thread_id):
+ async for msg in workflow.run("task", thread_id=thread_id, stream=True):
print(msg.text)
# Resume from checkpoint
- async for msg in workflow.run("continue", thread_id=thread_id):
+ async for msg in workflow.run("continue", thread_id=thread_id, stream=True):
print(msg.text)
Notes:
@@ -1812,7 +1810,7 @@ def with_manager(
class MyManager(MagenticManagerBase):
async def plan(self, context: MagenticContext) -> ChatMessage:
# Custom planning logic
- return ChatMessage("assistant", ["..."])
+ return ChatMessage(role="assistant", text="...")
manager = MyManager()
diff --git a/python/packages/orchestrations/tests/test_concurrent.py b/python/packages/orchestrations/tests/test_concurrent.py
index edc937a75e..f1853eb2e7 100644
--- a/python/packages/orchestrations/tests/test_concurrent.py
+++ b/python/packages/orchestrations/tests/test_concurrent.py
@@ -34,7 +34,7 @@ def __init__(self, id: str, reply_text: str) -> None:
@handler
async def run(self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorResponse]) -> None:
- response = AgentResponse(messages=ChatMessage("assistant", text=self._reply_text))
+ response = AgentResponse(messages=ChatMessage(role="assistant", text=self._reply_text))
full_conversation = list(request.messages) + list(response.messages)
await ctx.send_message(AgentExecutorResponse(self.id, response, full_conversation=full_conversation))
@@ -110,7 +110,7 @@ async def test_concurrent_default_aggregator_emits_single_user_and_assistants()
completed = False
output: list[ChatMessage] | None = None
- async for ev in wf.run_stream("prompt: hello world"):
+ async for ev in wf.run("prompt: hello world", stream=True):
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
completed = True
elif isinstance(ev, WorkflowOutputEvent):
@@ -148,7 +148,7 @@ async def summarize(results: list[AgentExecutorResponse]) -> str:
completed = False
output: str | None = None
- async for ev in wf.run_stream("prompt: custom"):
+ async for ev in wf.run("prompt: custom", stream=True):
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
completed = True
elif isinstance(ev, WorkflowOutputEvent):
@@ -179,7 +179,7 @@ def summarize_sync(results: list[AgentExecutorResponse], _ctx: WorkflowContext[A
completed = False
output: str | None = None
- async for ev in wf.run_stream("prompt: custom sync"):
+ async for ev in wf.run("prompt: custom sync", stream=True):
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
completed = True
elif isinstance(ev, WorkflowOutputEvent):
@@ -227,7 +227,7 @@ async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowCon
completed = False
output: str | None = None
- async for ev in wf.run_stream("prompt: instance test"):
+ async for ev in wf.run("prompt: instance test", stream=True):
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
completed = True
elif isinstance(ev, WorkflowOutputEvent):
@@ -265,7 +265,7 @@ async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowCon
completed = False
output: str | None = None
- async for ev in wf.run_stream("prompt: factory test"):
+ async for ev in wf.run("prompt: factory test", stream=True):
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
completed = True
elif isinstance(ev, WorkflowOutputEvent):
@@ -301,7 +301,7 @@ async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowCon
completed = False
output: str | None = None
- async for ev in wf.run_stream("prompt: factory test"):
+ async for ev in wf.run("prompt: factory test", stream=True):
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
completed = True
elif isinstance(ev, WorkflowOutputEvent):
@@ -351,7 +351,7 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None:
wf = ConcurrentBuilder().participants(list(participants)).with_checkpointing(storage).build()
baseline_output: list[ChatMessage] | None = None
- async for ev in wf.run_stream("checkpoint concurrent"):
+ async for ev in wf.run("checkpoint concurrent", stream=True):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
@@ -375,7 +375,7 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None:
wf_resume = ConcurrentBuilder().participants(list(resumed_participants)).with_checkpointing(storage).build()
resumed_output: list[ChatMessage] | None = None
- async for ev in wf_resume.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id):
+ async for ev in wf_resume.run(checkpoint_id=resume_checkpoint.checkpoint_id, stream=True):
if isinstance(ev, WorkflowOutputEvent):
resumed_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
@@ -397,7 +397,7 @@ async def test_concurrent_checkpoint_runtime_only() -> None:
wf = ConcurrentBuilder().participants(agents).build()
baseline_output: list[ChatMessage] | None = None
- async for ev in wf.run_stream("runtime checkpoint test", checkpoint_storage=storage):
+ async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
@@ -418,7 +418,9 @@ async def test_concurrent_checkpoint_runtime_only() -> None:
wf_resume = ConcurrentBuilder().participants(resumed_agents).build()
resumed_output: list[ChatMessage] | None = None
- async for ev in wf_resume.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage):
+ async for ev in wf_resume.run(
+ checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage, stream=True
+ ):
if isinstance(ev, WorkflowOutputEvent):
resumed_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
@@ -445,7 +447,7 @@ async def test_concurrent_checkpoint_runtime_overrides_buildtime() -> None:
wf = ConcurrentBuilder().participants(agents).with_checkpointing(buildtime_storage).build()
baseline_output: list[ChatMessage] | None = None
- async for ev in wf.run_stream("override test", checkpoint_storage=runtime_storage):
+ async for ev in wf.run("override test", checkpoint_storage=runtime_storage, stream=True):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
@@ -527,7 +529,7 @@ def create_agent3() -> Executor:
completed = False
output: list[ChatMessage] | None = None
- async for ev in wf.run_stream("test prompt"):
+ async for ev in wf.run("test prompt", stream=True):
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
completed = True
elif isinstance(ev, WorkflowOutputEvent):
diff --git a/python/packages/orchestrations/tests/test_group_chat.py b/python/packages/orchestrations/tests/test_group_chat.py
index 2e6e2f0ce9..44485f4abf 100644
--- a/python/packages/orchestrations/tests/test_group_chat.py
+++ b/python/packages/orchestrations/tests/test_group_chat.py
@@ -1,6 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
-from collections.abc import AsyncIterable, Callable, Sequence
+from collections.abc import AsyncIterable, Awaitable, Callable, Sequence
from typing import Any, cast
import pytest
@@ -38,29 +38,26 @@ def __init__(self, agent_name: str, reply_text: str, **kwargs: Any) -> None:
super().__init__(name=agent_name, description=f"Stub agent {agent_name}", **kwargs)
self._reply_text = reply_text
- async def run( # type: ignore[override]
+ def run( # type: ignore[override]
self,
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
*,
+ stream: bool = False,
thread: AgentThread | None = None,
**kwargs: Any,
- ) -> AgentResponse:
- response = ChatMessage("assistant", [self._reply_text], author_name=self.name)
- return AgentResponse(messages=[response])
+ ) -> Awaitable[AgentResponse] | AsyncIterable[AgentResponseUpdate]:
+ if stream:
+ return self._run_stream_impl()
+ return self._run_impl()
- def run_stream( # type: ignore[override]
- self,
- messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
- *,
- thread: AgentThread | None = None,
- **kwargs: Any,
- ) -> AsyncIterable[AgentResponseUpdate]:
- async def _stream() -> AsyncIterable[AgentResponseUpdate]:
- yield AgentResponseUpdate(
- contents=[Content.from_text(text=self._reply_text)], role="assistant", author_name=self.name
- )
+ async def _run_impl(self) -> AgentResponse:
+ response = ChatMessage(role="assistant", text=self._reply_text, author_name=self.name)
+ return AgentResponse(messages=[response])
- return _stream()
+ async def _run_stream_impl(self) -> AsyncIterable[AgentResponseUpdate]:
+ yield AgentResponseUpdate(
+ contents=[Content.from_text(text=self._reply_text)], role="assistant", author_name=self.name
+ )
class MockChatClient:
@@ -68,10 +65,9 @@ class MockChatClient:
additional_properties: dict[str, Any]
- async def get_response(self, messages: Any, **kwargs: Any) -> ChatResponse:
- raise NotImplementedError
-
- def get_streaming_response(self, messages: Any, **kwargs: Any) -> AsyncIterable[ChatResponseUpdate]:
+ async def get_response(
+ self, messages: Any, stream: bool = False, **kwargs: Any
+ ) -> ChatResponse | AsyncIterable[ChatResponseUpdate]:
raise NotImplementedError
@@ -126,48 +122,6 @@ async def run(
value=payload,
)
- def run_stream(
- self,
- messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
- *,
- thread: AgentThread | None = None,
- **kwargs: Any,
- ) -> AsyncIterable[AgentResponseUpdate]:
- if self._call_count == 0:
- self._call_count += 1
-
- async def _stream_initial() -> AsyncIterable[AgentResponseUpdate]:
- yield AgentResponseUpdate(
- contents=[
- Content.from_text(
- text=(
- '{"terminate": false, "reason": "Selecting agent", '
- '"next_speaker": "agent", "final_message": null}'
- )
- )
- ],
- role="assistant",
- author_name=self.name,
- )
-
- return _stream_initial()
-
- async def _stream_final() -> AsyncIterable[AgentResponseUpdate]:
- yield AgentResponseUpdate(
- contents=[
- Content.from_text(
- text=(
- '{"terminate": true, "reason": "Task complete", '
- '"next_speaker": null, "final_message": "agent manager final"}'
- )
- )
- ],
- role="assistant",
- author_name=self.name,
- )
-
- return _stream_final()
-
def make_sequence_selector() -> Callable[[GroupChatState], str]:
state_counter = {"value": 0}
@@ -192,7 +146,7 @@ def __init__(self) -> None:
self._round = 0
async def plan(self, magentic_context: MagenticContext) -> ChatMessage:
- return ChatMessage("assistant", ["plan"], author_name="magentic_manager")
+ return ChatMessage(role="assistant", text="plan", author_name="magentic_manager")
async def replan(self, magentic_context: MagenticContext) -> ChatMessage:
return await self.plan(magentic_context)
@@ -218,7 +172,7 @@ async def create_progress_ledger(self, magentic_context: MagenticContext) -> Mag
)
async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage:
- return ChatMessage("assistant", ["final"], author_name="magentic_manager")
+ return ChatMessage(role="assistant", text="final", author_name="magentic_manager")
async def test_group_chat_builder_basic_flow() -> None:
@@ -235,7 +189,7 @@ async def test_group_chat_builder_basic_flow() -> None:
)
outputs: list[list[ChatMessage]] = []
- async for event in workflow.run_stream("coordinate task"):
+ async for event in workflow.run("coordinate task", stream=True):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, list):
@@ -263,8 +217,8 @@ async def test_group_chat_as_agent_accepts_conversation() -> None:
agent = workflow.as_agent(name="group-chat-agent")
conversation = [
- ChatMessage("user", ["kickoff"], author_name="user"),
- ChatMessage("assistant", ["noted"], author_name="alpha"),
+ ChatMessage(role="user", text="kickoff", author_name="user"),
+ ChatMessage(role="assistant", text="noted", author_name="alpha"),
]
response = await agent.run(conversation)
@@ -347,16 +301,19 @@ class AgentWithoutName(BaseAgent):
def __init__(self) -> None:
super().__init__(name="", description="test")
- async def run(self, messages: Any = None, *, thread: Any = None, **kwargs: Any) -> AgentResponse:
- return AgentResponse(messages=[])
+ def run(
+ self, messages: Any = None, *, stream: bool = False, thread: Any = None, **kwargs: Any
+ ) -> AgentResponse | AsyncIterable[AgentResponseUpdate]:
+ if stream:
- def run_stream(
- self, messages: Any = None, *, thread: Any = None, **kwargs: Any
- ) -> AsyncIterable[AgentResponseUpdate]:
- async def _stream() -> AsyncIterable[AgentResponseUpdate]:
- yield AgentResponseUpdate(contents=[])
+ async def _stream() -> AsyncIterable[AgentResponseUpdate]:
+ yield AgentResponseUpdate(contents=[])
- return _stream()
+ return _stream()
+ return self._run_impl()
+
+ async def _run_impl(self) -> AgentResponse:
+ return AgentResponse(messages=[])
agent = AgentWithoutName()
@@ -404,7 +361,7 @@ def selector(state: GroupChatState) -> str:
)
outputs: list[list[ChatMessage]] = []
- async for event in workflow.run_stream("test task"):
+ async for event in workflow.run("test task", stream=True):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, list):
@@ -439,7 +396,7 @@ def termination_condition(conversation: list[ChatMessage]) -> bool:
)
outputs: list[list[ChatMessage]] = []
- async for event in workflow.run_stream("test task"):
+ async for event in workflow.run("test task", stream=True):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, list):
@@ -467,7 +424,7 @@ async def test_termination_condition_agent_manager_finalizes(self) -> None:
)
outputs: list[list[ChatMessage]] = []
- async for event in workflow.run_stream("test task"):
+ async for event in workflow.run("test task", stream=True):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, list):
@@ -489,7 +446,7 @@ def selector(state: GroupChatState) -> str:
workflow = GroupChatBuilder().with_orchestrator(selection_func=selector).participants([agent]).build()
with pytest.raises(RuntimeError, match="Selection function returned unknown participant 'unknown_agent'"):
- async for _ in workflow.run_stream("test task"):
+ async for _ in workflow.run("test task", stream=True):
pass
@@ -515,7 +472,7 @@ def selector(state: GroupChatState) -> str:
)
outputs: list[list[ChatMessage]] = []
- async for event in workflow.run_stream("test task"):
+ async for event in workflow.run("test task", stream=True):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, list):
@@ -544,7 +501,7 @@ def selector(state: GroupChatState) -> str:
)
with pytest.raises(ValueError, match="At least one ChatMessage is required to start the group chat workflow."):
- async for _ in workflow.run_stream([]):
+ async for _ in workflow.run([], stream=True):
pass
async def test_handle_string_input(self) -> None:
@@ -568,7 +525,7 @@ def selector(state: GroupChatState) -> str:
)
outputs: list[list[ChatMessage]] = []
- async for event in workflow.run_stream("test string"):
+ async for event in workflow.run("test string", stream=True):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, list):
@@ -578,7 +535,7 @@ def selector(state: GroupChatState) -> str:
async def test_handle_chat_message_input(self) -> None:
"""Test handling ChatMessage input directly."""
- task_message = ChatMessage("user", ["test message"])
+ task_message = ChatMessage(role="user", text="test message")
def selector(state: GroupChatState) -> str:
# Verify the task message was preserved in conversation
@@ -597,7 +554,7 @@ def selector(state: GroupChatState) -> str:
)
outputs: list[list[ChatMessage]] = []
- async for event in workflow.run_stream(task_message):
+ async for event in workflow.run(task_message, stream=True):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, list):
@@ -608,8 +565,8 @@ def selector(state: GroupChatState) -> str:
async def test_handle_conversation_list_input(self) -> None:
"""Test handling conversation list preserves context."""
conversation = [
- ChatMessage("system", ["system message"]),
- ChatMessage("user", ["user message"]),
+ ChatMessage(role="system", text="system message"),
+ ChatMessage(role="user", text="user message"),
]
def selector(state: GroupChatState) -> str:
@@ -629,7 +586,7 @@ def selector(state: GroupChatState) -> str:
)
outputs: list[list[ChatMessage]] = []
- async for event in workflow.run_stream(conversation):
+ async for event in workflow.run(conversation, stream=True):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, list):
@@ -661,7 +618,7 @@ def selector(state: GroupChatState) -> str:
)
outputs: list[list[ChatMessage]] = []
- async for event in workflow.run_stream("test"):
+ async for event in workflow.run("test", stream=True):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, list):
@@ -696,7 +653,7 @@ def selector(state: GroupChatState) -> str:
)
outputs: list[list[ChatMessage]] = []
- async for event in workflow.run_stream("test"):
+ async for event in workflow.run("test", stream=True):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, list):
@@ -728,7 +685,7 @@ async def test_group_chat_checkpoint_runtime_only() -> None:
)
baseline_output: list[ChatMessage] | None = None
- async for ev in wf.run_stream("runtime checkpoint test", checkpoint_storage=storage):
+ async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = cast(list[ChatMessage], ev.data) if isinstance(ev.data, list) else None # type: ignore
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
@@ -766,7 +723,7 @@ async def test_group_chat_checkpoint_runtime_overrides_buildtime() -> None:
.build()
)
baseline_output: list[ChatMessage] | None = None
- async for ev in wf.run_stream("override test", checkpoint_storage=runtime_storage):
+ async for ev in wf.run("override test", checkpoint_storage=runtime_storage, stream=True):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = cast(list[ChatMessage], ev.data) if isinstance(ev.data, list) else None # type: ignore
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
@@ -814,7 +771,7 @@ async def selector(state: GroupChatState) -> str:
# Run until we get a request info event (should be before beta, not alpha)
request_events: list[RequestInfoEvent] = []
- async for event in workflow.run_stream("test task"):
+ async for event in workflow.run("test task", stream=True):
if isinstance(event, RequestInfoEvent) and isinstance(event.data, AgentExecutorResponse):
request_events.append(event)
# Don't break - let stream complete naturally when paused
@@ -866,7 +823,7 @@ async def selector(state: GroupChatState) -> str:
# Run until we get a request info event
request_events: list[RequestInfoEvent] = []
- async for event in workflow.run_stream("test task"):
+ async for event in workflow.run("test task", stream=True):
if isinstance(event, RequestInfoEvent) and isinstance(event.data, AgentExecutorResponse):
request_events.append(event)
break
@@ -970,7 +927,7 @@ def create_beta() -> StubAgent:
assert call_count == 2
outputs: list[WorkflowOutputEvent] = []
- async for event in workflow.run_stream("coordinate task"):
+ async for event in workflow.run("coordinate task", stream=True):
if isinstance(event, WorkflowOutputEvent):
outputs.append(event)
@@ -1035,7 +992,7 @@ def create_beta() -> StubAgent:
)
outputs: list[WorkflowOutputEvent] = []
- async for event in workflow.run_stream("checkpoint test"):
+ async for event in workflow.run("checkpoint test", stream=True):
if isinstance(event, WorkflowOutputEvent):
outputs.append(event)
@@ -1163,7 +1120,7 @@ def agent_factory() -> ChatAgent:
assert factory_call_count == 1
outputs: list[WorkflowOutputEvent] = []
- async for event in workflow.run_stream("coordinate task"):
+ async for event in workflow.run("coordinate task", stream=True):
if isinstance(event, WorkflowOutputEvent):
outputs.append(event)
diff --git a/python/packages/orchestrations/tests/test_handoff.py b/python/packages/orchestrations/tests/test_handoff.py
index d1fe70eff6..2242508aa7 100644
--- a/python/packages/orchestrations/tests/test_handoff.py
+++ b/python/packages/orchestrations/tests/test_handoff.py
@@ -1,6 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
-from collections.abc import AsyncIterable
+from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock
@@ -12,25 +12,26 @@
ChatResponseUpdate,
Content,
RequestInfoEvent,
+ ResponseStream,
WorkflowEvent,
WorkflowOutputEvent,
resolve_agent_id,
- use_function_invocation,
)
+from agent_framework._clients import BaseChatClient
+from agent_framework._middleware import ChatMiddlewareLayer
+from agent_framework._tools import FunctionInvocationLayer
from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder
-@use_function_invocation
-class MockChatClient:
+class MockChatClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
"""Mock chat client for testing handoff workflows."""
- additional_properties: dict[str, Any]
-
def __init__(
self,
- name: str,
*,
+ name: str = "",
handoff_to: str | None = None,
+ **kwargs: Any,
) -> None:
"""Initialize the mock chat client.
@@ -39,24 +40,45 @@ def __init__(
handoff_to: The name of the agent to hand off to, or None for no handoff.
This is hardcoded for testing purposes so that the agent always attempts to hand off.
"""
+ ChatMiddlewareLayer.__init__(self)
+ FunctionInvocationLayer.__init__(self)
+ BaseChatClient.__init__(self)
self._name = name
self._handoff_to = handoff_to
self._call_index = 0
- async def get_response(self, messages: Any, **kwargs: Any) -> ChatResponse:
- contents = _build_reply_contents(self._name, self._handoff_to, self._next_call_id())
- reply = ChatMessage(
- role="assistant",
- contents=contents,
- )
- return ChatResponse(messages=reply, response_id="mock_response")
+ def _inner_get_response(
+ self,
+ *,
+ messages: Sequence[ChatMessage],
+ stream: bool,
+ options: Mapping[str, Any],
+ **kwargs: Any,
+ ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
+ if stream:
+ return self._build_streaming_response(options=dict(options))
+
+ async def _get() -> ChatResponse:
+ contents = _build_reply_contents(self._name, self._handoff_to, self._next_call_id())
+ reply = ChatMessage(
+ role="assistant",
+ contents=contents,
+ )
+ return ChatResponse(messages=reply, response_id="mock_response")
+
+ return _get()
- def get_streaming_response(self, messages: Any, **kwargs: Any) -> AsyncIterable[ChatResponseUpdate]:
+ def _build_streaming_response(self, *, options: dict[str, Any]) -> ResponseStream[ChatResponseUpdate, ChatResponse]:
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
contents = _build_reply_contents(self._name, self._handoff_to, self._next_call_id())
- yield ChatResponseUpdate(contents=contents, role="assistant")
+ yield ChatResponseUpdate(contents=contents, role="assistant", finish_reason="stop")
- return _stream()
+ def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
+ response_format = options.get("response_format")
+ output_format_type = response_format if isinstance(response_format, type) else None
+ return ChatResponse.from_updates(updates, output_format_type=output_format_type)
+
+ return ResponseStream(_stream(), finalizer=_finalize)
def _next_call_id(self) -> str | None:
if not self._handoff_to:
@@ -99,7 +121,7 @@ def __init__(
handoff_to: The name of the agent to hand off to, or None for no handoff.
This is hardcoded for testing purposes so that the agent always attempts to hand off.
"""
- super().__init__(chat_client=MockChatClient(name, handoff_to=handoff_to), name=name, id=name)
+ super().__init__(chat_client=MockChatClient(name=name, handoff_to=handoff_to), name=name, id=name)
async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]:
@@ -127,7 +149,7 @@ async def test_handoff():
# Start conversation - triage hands off to specialist then escalation
# escalation won't trigger a handoff, so the response from it will become
# a request for user input because autonomous mode is not enabled by default.
- events = await _drain(workflow.run_stream("Need technical support"))
+ events = await _drain(workflow.run("Need technical support", stream=True))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests
@@ -161,7 +183,7 @@ async def test_autonomous_mode_yields_output_without_user_request():
.build()
)
- events = await _drain(workflow.run_stream("Package arrived broken"))
+ events = await _drain(workflow.run("Package arrived broken", stream=True))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert not requests, "Autonomous mode should not request additional user input"
@@ -187,7 +209,7 @@ async def test_autonomous_mode_resumes_user_input_on_turn_limit():
.build()
)
- events = await _drain(workflow.run_stream("Start"))
+ events = await _drain(workflow.run("Start", stream=True))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests and len(requests) == 1, "Turn limit should force a user input request"
assert requests[0].source_executor_id == worker.name
@@ -230,12 +252,14 @@ async def async_termination(conv: list[ChatMessage]) -> bool:
.build()
)
- events = await _drain(workflow.run_stream("First user message"))
+ events = await _drain(workflow.run("First user message", stream=True))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests
events = await _drain(
- workflow.send_responses_streaming({requests[-1].request_id: [ChatMessage("user", ["Second user message"])]})
+ workflow.send_responses_streaming({
+ requests[-1].request_id: [ChatMessage(role="user", text="Second user message")]
+ })
)
outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)]
assert len(outputs) == 1
@@ -257,7 +281,7 @@ async def mock_get_response(messages: Any, options: dict[str, Any] | None = None
if options:
recorded_tool_choices.append(options.get("tool_choice"))
return ChatResponse(
- messages=[ChatMessage("assistant", ["Response"])],
+ messages=[ChatMessage(role="assistant", text="Response")],
response_id="test_response",
)
@@ -480,13 +504,13 @@ def create_specialist() -> MockHandoffAgent:
# Factories should be called during build
assert call_count == 2
- events = await _drain(workflow.run_stream("Need help"))
+ events = await _drain(workflow.run("Need help", stream=True))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests
# Follow-up message
events = await _drain(
- workflow.send_responses_streaming({requests[-1].request_id: [ChatMessage("user", ["More details"])]})
+ workflow.send_responses_streaming({requests[-1].request_id: [ChatMessage(role="user", text="More details")]})
)
outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)]
assert outputs
@@ -551,7 +575,7 @@ def create_specialist_b() -> MockHandoffAgent:
)
# Start conversation - triage hands off to specialist_a
- events = await _drain(workflow.run_stream("Initial request"))
+ events = await _drain(workflow.run("Initial request", stream=True))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests
@@ -560,7 +584,7 @@ def create_specialist_b() -> MockHandoffAgent:
# Second user message - specialist_a hands off to specialist_b
events = await _drain(
- workflow.send_responses_streaming({requests[-1].request_id: [ChatMessage("user", ["Need escalation"])]})
+ workflow.send_responses_streaming({requests[-1].request_id: [ChatMessage(role="user", text="Need escalation")]})
)
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests
@@ -590,12 +614,12 @@ def create_specialist() -> MockHandoffAgent:
)
# Run workflow and capture output
- events = await _drain(workflow.run_stream("checkpoint test"))
+ events = await _drain(workflow.run("checkpoint test", stream=True))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests
events = await _drain(
- workflow.send_responses_streaming({requests[-1].request_id: [ChatMessage("user", ["follow up"])]})
+ workflow.send_responses_streaming({requests[-1].request_id: [ChatMessage(role="user", text="follow up")]})
)
outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)]
assert outputs, "Should have workflow output after termination condition is met"
@@ -668,7 +692,7 @@ def create_specialist() -> MockHandoffAgent:
.build()
)
- events = await _drain(workflow.run_stream("Issue"))
+ events = await _drain(workflow.run("Issue", stream=True))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests and len(requests) == 1
assert requests[0].source_executor_id == "specialist"
diff --git a/python/packages/orchestrations/tests/test_magentic.py b/python/packages/orchestrations/tests/test_magentic.py
index 90120a130c..67106b9011 100644
--- a/python/packages/orchestrations/tests/test_magentic.py
+++ b/python/packages/orchestrations/tests/test_magentic.py
@@ -1,7 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
-from collections.abc import AsyncIterable, Sequence
+from collections.abc import AsyncIterable, Awaitable, Sequence
from dataclasses import dataclass
from typing import Any, ClassVar, cast
@@ -152,29 +152,27 @@ def __init__(self, agent_name: str, reply_text: str, **kwargs: Any) -> None:
super().__init__(name=agent_name, description=f"Stub agent {agent_name}", **kwargs)
self._reply_text = reply_text
- async def run( # type: ignore[override]
+ def run( # type: ignore[override]
self,
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
*,
+ stream: bool = False,
thread: AgentThread | None = None,
**kwargs: Any,
- ) -> AgentResponse:
- response = ChatMessage("assistant", [self._reply_text], author_name=self.name)
- return AgentResponse(messages=[response])
+ ) -> Awaitable[AgentResponse] | AsyncIterable[AgentResponseUpdate]:
+ if stream:
+ return self._run_stream()
- def run_stream( # type: ignore[override]
- self,
- messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
- *,
- thread: AgentThread | None = None,
- **kwargs: Any,
- ) -> AsyncIterable[AgentResponseUpdate]:
- async def _stream() -> AsyncIterable[AgentResponseUpdate]:
- yield AgentResponseUpdate(
- contents=[Content.from_text(text=self._reply_text)], role="assistant", author_name=self.name
- )
+ async def _run() -> AgentResponse:
+ response = ChatMessage("assistant", [self._reply_text], author_name=self.name)
+ return AgentResponse(messages=[response])
- return _stream()
+ return _run()
+
+ async def _run_stream(self) -> AsyncIterable[AgentResponseUpdate]:
+ yield AgentResponseUpdate(
+ contents=[Content.from_text(text=self._reply_text)], role="assistant", author_name=self.name
+ )
class DummyExec(Executor):
@@ -198,7 +196,7 @@ async def test_magentic_builder_returns_workflow_and_runs() -> None:
outputs: list[ChatMessage] = []
orchestrator_event_count = 0
- async for event in workflow.run_stream("compose summary"):
+ async for event in workflow.run("compose summary", stream=True):
if isinstance(event, WorkflowOutputEvent):
msg = event.data
if isinstance(msg, list):
@@ -249,7 +247,7 @@ async def test_magentic_workflow_plan_review_approval_to_completion():
wf = MagenticBuilder().participants([DummyExec("agentA")]).with_manager(manager=manager).with_plan_review().build()
req_event: RequestInfoEvent | None = None
- async for ev in wf.run_stream("do work"):
+ async for ev in wf.run("do work", stream=True):
if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticPlanReviewRequest:
req_event = ev
assert req_event is not None
@@ -294,7 +292,7 @@ async def replan(self, magentic_context: MagenticContext) -> ChatMessage: # typ
# Wait for the initial plan review request
req_event: RequestInfoEvent | None = None
- async for ev in wf.run_stream("do work"):
+ async for ev in wf.run("do work", stream=True):
if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticPlanReviewRequest:
req_event = ev
assert req_event is not None
@@ -337,7 +335,7 @@ async def test_magentic_orchestrator_round_limit_produces_partial_result():
)
events: list[WorkflowEvent] = []
- async for ev in wf.run_stream("round limit test"):
+ async for ev in wf.run("round limit test", stream=True):
events.append(ev)
idle_status = next(
@@ -370,7 +368,7 @@ async def test_magentic_checkpoint_resume_round_trip():
task_text = "checkpoint task"
req_event: RequestInfoEvent | None = None
- async for ev in wf.run_stream(task_text):
+ async for ev in wf.run(task_text, stream=True):
if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticPlanReviewRequest:
req_event = ev
assert req_event is not None
@@ -393,8 +391,9 @@ async def test_magentic_checkpoint_resume_round_trip():
completed: WorkflowOutputEvent | None = None
req_event = None
- async for event in wf_resume.run_stream(
+ async for event in wf_resume.run(
resume_checkpoint.checkpoint_id,
+ stream=True,
):
if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
req_event = event
@@ -419,26 +418,24 @@ async def test_magentic_checkpoint_resume_round_trip():
class StubManagerAgent(BaseAgent):
"""Stub agent for testing StandardMagenticManager."""
- async def run(
+ def run(
self,
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
*,
+ stream: bool = False,
thread: Any = None,
**kwargs: Any,
- ) -> AgentResponse:
- return AgentResponse(messages=[ChatMessage("assistant", ["ok"])])
+ ) -> Awaitable[AgentResponse] | AsyncIterable[AgentResponseUpdate]:
+ if stream:
+ return self._run_stream()
- def run_stream(
- self,
- messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
- *,
- thread: Any = None,
- **kwargs: Any,
- ) -> AsyncIterable[AgentResponseUpdate]:
- async def _gen() -> AsyncIterable[AgentResponseUpdate]:
- yield AgentResponseUpdate(message_deltas=[ChatMessage("assistant", ["ok"])])
+ async def _run() -> AgentResponse:
+ return AgentResponse(messages=[ChatMessage("assistant", ["ok"])])
+
+ return _run()
- return _gen()
+ async def _run_stream(self) -> AsyncIterable[AgentResponseUpdate]:
+ yield AgentResponseUpdate(message_deltas=[ChatMessage("assistant", ["ok"])])
async def test_standard_manager_plan_and_replan_via_complete_monkeypatch():
@@ -538,16 +535,22 @@ class StubThreadAgent(BaseAgent):
def __init__(self, name: str | None = None) -> None:
super().__init__(name=name or "agentA")
- async def run_stream(self, messages=None, *, thread=None, **kwargs): # type: ignore[override]
+ def run(self, messages=None, *, stream: bool = False, thread=None, **kwargs): # type: ignore[override]
+ if stream:
+ return self._run_stream()
+
+ async def _run():
+ return AgentResponse(messages=[ChatMessage("assistant", ["thread-ok"], author_name=self.name)])
+
+ return _run()
+
+ async def _run_stream(self):
yield AgentResponseUpdate(
contents=[Content.from_text(text="thread-ok")],
author_name=self.name,
role="assistant",
)
- async def run(self, messages=None, *, thread=None, **kwargs): # type: ignore[override]
- return AgentResponse(messages=[ChatMessage("assistant", ["thread-ok"], author_name=self.name)])
-
class StubAssistantsClient:
pass # class name used for branch detection
@@ -560,16 +563,22 @@ def __init__(self) -> None:
super().__init__(name="agentA")
self.chat_client = StubAssistantsClient() # type name contains 'AssistantsClient'
- async def run_stream(self, messages=None, *, thread=None, **kwargs): # type: ignore[override]
+ def run(self, messages=None, *, stream: bool = False, thread=None, **kwargs): # type: ignore[override]
+ if stream:
+ return self._run_stream()
+
+ async def _run():
+ return AgentResponse(messages=[ChatMessage("assistant", ["assistants-ok"], author_name=self.name)])
+
+ return _run()
+
+ async def _run_stream(self):
yield AgentResponseUpdate(
contents=[Content.from_text(text="assistants-ok")],
author_name=self.name,
role="assistant",
)
- async def run(self, messages=None, *, thread=None, **kwargs): # type: ignore[override]
- return AgentResponse(messages=[ChatMessage("assistant", ["assistants-ok"], author_name=self.name)])
-
async def _collect_agent_responses_setup(participant: AgentProtocol) -> list[ChatMessage]:
captured: list[ChatMessage] = []
@@ -584,7 +593,7 @@ async def _collect_agent_responses_setup(participant: AgentProtocol) -> list[Cha
# Run a bounded stream to allow one invoke and then completion
events: list[WorkflowEvent] = []
- async for ev in wf.run_stream("task"): # plan review disabled
+ async for ev in wf.run("task", stream=True): # plan review disabled
events.append(ev)
if isinstance(ev, WorkflowOutputEvent) and isinstance(ev.data, AgentResponseUpdate):
captured.append(
@@ -630,7 +639,7 @@ async def test_magentic_checkpoint_resume_inner_loop_superstep():
.build()
)
- async for event in workflow.run_stream("inner-loop task"):
+ async for event in workflow.run("inner-loop task", stream=True):
if isinstance(event, WorkflowOutputEvent):
break
@@ -646,7 +655,7 @@ async def test_magentic_checkpoint_resume_inner_loop_superstep():
)
completed: WorkflowOutputEvent | None = None
- async for event in resumed.run_stream(checkpoint_id=inner_loop_checkpoint.checkpoint_id): # type: ignore[reportUnknownMemberType]
+ async for event in resumed.run(checkpoint_id=inner_loop_checkpoint.checkpoint_id, stream=True): # type: ignore[reportUnknownMemberType]
if isinstance(event, WorkflowOutputEvent):
completed = event
@@ -668,7 +677,7 @@ async def test_magentic_checkpoint_resume_from_saved_state():
.build()
)
- async for event in workflow.run_stream("checkpoint resume task"):
+ async for event in workflow.run("checkpoint resume task", stream=True):
if isinstance(event, WorkflowOutputEvent):
break
@@ -686,7 +695,7 @@ async def test_magentic_checkpoint_resume_from_saved_state():
)
completed: WorkflowOutputEvent | None = None
- async for event in resumed_workflow.run_stream(checkpoint_id=resumed_state.checkpoint_id):
+ async for event in resumed_workflow.run(checkpoint_id=resumed_state.checkpoint_id, stream=True):
if isinstance(event, WorkflowOutputEvent):
completed = event
@@ -708,7 +717,7 @@ async def test_magentic_checkpoint_resume_rejects_participant_renames():
)
req_event: RequestInfoEvent | None = None
- async for event in workflow.run_stream("task"):
+ async for event in workflow.run("task", stream=True):
if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
req_event = event
@@ -728,7 +737,8 @@ async def test_magentic_checkpoint_resume_rejects_participant_renames():
)
with pytest.raises(WorkflowCheckpointException, match="Workflow graph has changed"):
- async for _ in renamed_workflow.run_stream(
+ async for _ in renamed_workflow.run(
+ stream=True,
checkpoint_id=target_checkpoint.checkpoint_id, # type: ignore[reportUnknownMemberType]
):
pass
@@ -764,7 +774,7 @@ async def test_magentic_stall_and_reset_reach_limits():
wf = MagenticBuilder().participants([DummyExec("agentA")]).with_manager(manager=manager).build()
events: list[WorkflowEvent] = []
- async for ev in wf.run_stream("test limits"):
+ async for ev in wf.run("test limits", stream=True):
events.append(ev)
idle_status = next(
@@ -789,7 +799,7 @@ async def test_magentic_checkpoint_runtime_only() -> None:
wf = MagenticBuilder().participants([DummyExec("agentA")]).with_manager(manager=manager).build()
baseline_output: ChatMessage | None = None
- async for ev in wf.run_stream("runtime checkpoint test", checkpoint_storage=storage):
+ async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
@@ -827,7 +837,7 @@ async def test_magentic_checkpoint_runtime_overrides_buildtime() -> None:
)
baseline_output: ChatMessage | None = None
- async for ev in wf.run_stream("override test", checkpoint_storage=runtime_storage):
+ async for ev in wf.run("override test", checkpoint_storage=runtime_storage, stream=True):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
@@ -886,7 +896,7 @@ async def test_magentic_checkpoint_restore_no_duplicate_history():
ChatMessage("user", ["task_msg"]),
]
- async for event in wf.run_stream(conversation):
+ async for event in wf.run(conversation, stream=True):
if isinstance(event, WorkflowStatusEvent) and event.state in (
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
@@ -996,7 +1006,7 @@ def create_agent() -> StubAgent:
assert call_count == 1
outputs: list[WorkflowOutputEvent] = []
- async for event in workflow.run_stream("test task"):
+ async for event in workflow.run("test task", stream=True):
if isinstance(event, WorkflowOutputEvent):
outputs.append(event)
@@ -1043,7 +1053,7 @@ def create_agent() -> StubAgent:
)
outputs: list[WorkflowOutputEvent] = []
- async for event in workflow.run_stream("checkpoint test"):
+ async for event in workflow.run("checkpoint test", stream=True):
if isinstance(event, WorkflowOutputEvent):
outputs.append(event)
@@ -1100,7 +1110,7 @@ def manager_factory() -> MagenticManagerBase:
assert factory_call_count == 1
outputs: list[WorkflowOutputEvent] = []
- async for event in workflow.run_stream("test task"):
+ async for event in workflow.run("test task", stream=True):
if isinstance(event, WorkflowOutputEvent):
outputs.append(event)
@@ -1129,7 +1139,7 @@ def agent_factory() -> AgentProtocol:
# Verify workflow can be started (may not complete successfully due to stub behavior)
event_count = 0
- async for _ in workflow.run_stream("test task"):
+ async for _ in workflow.run("test task", stream=True):
event_count += 1
if event_count > 10:
break
diff --git a/python/packages/orchestrations/tests/test_sequential.py b/python/packages/orchestrations/tests/test_sequential.py
index b6441ff592..322f3ba7c0 100644
--- a/python/packages/orchestrations/tests/test_sequential.py
+++ b/python/packages/orchestrations/tests/test_sequential.py
@@ -1,6 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
-from collections.abc import AsyncIterable
+from collections.abc import AsyncIterable, Awaitable
from typing import Any
import pytest
@@ -27,22 +27,23 @@
class _EchoAgent(BaseAgent):
"""Simple agent that appends a single assistant message with its name."""
- async def run( # type: ignore[override]
+ def run( # type: ignore[override]
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
+ stream: bool = False,
thread: AgentThread | None = None,
**kwargs: Any,
- ) -> AgentResponse:
- return AgentResponse(messages=[ChatMessage("assistant", [f"{self.name} reply"])])
+ ) -> Awaitable[AgentResponse] | AsyncIterable[AgentResponseUpdate]:
+ if stream:
+ return self._run_stream()
- async def run_stream( # type: ignore[override]
- self,
- messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
- *,
- thread: AgentThread | None = None,
- **kwargs: Any,
- ) -> AsyncIterable[AgentResponseUpdate]:
+ async def _run() -> AgentResponse:
+ return AgentResponse(messages=[ChatMessage("assistant", [f"{self.name} reply"])])
+
+ return _run()
+
+ async def _run_stream(self) -> AsyncIterable[AgentResponseUpdate]:
# Minimal async generator with one assistant update
yield AgentResponseUpdate(contents=[Content.from_text(text=f"{self.name} reply")])
@@ -104,7 +105,7 @@ async def test_sequential_agents_append_to_context() -> None:
completed = False
output: list[ChatMessage] | None = None
- async for ev in wf.run_stream("hello sequential"):
+ async for ev in wf.run("hello sequential", stream=True):
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
completed = True
elif isinstance(ev, WorkflowOutputEvent):
@@ -137,7 +138,7 @@ def create_agent2() -> _EchoAgent:
completed = False
output: list[ChatMessage] | None = None
- async for ev in wf.run_stream("hello factories"):
+ async for ev in wf.run("hello factories", stream=True):
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
completed = True
elif isinstance(ev, WorkflowOutputEvent):
@@ -163,7 +164,7 @@ async def test_sequential_with_custom_executor_summary() -> None:
completed = False
output: list[ChatMessage] | None = None
- async for ev in wf.run_stream("topic X"):
+ async for ev in wf.run("topic X", stream=True):
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
completed = True
elif isinstance(ev, WorkflowOutputEvent):
@@ -194,7 +195,7 @@ def create_summarizer() -> _SummarizerExec:
completed = False
output: list[ChatMessage] | None = None
- async for ev in wf.run_stream("topic Y"):
+ async for ev in wf.run("topic Y", stream=True):
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
completed = True
elif isinstance(ev, WorkflowOutputEvent):
@@ -219,7 +220,7 @@ async def test_sequential_checkpoint_resume_round_trip() -> None:
wf = SequentialBuilder().participants(list(initial_agents)).with_checkpointing(storage).build()
baseline_output: list[ChatMessage] | None = None
- async for ev in wf.run_stream("checkpoint sequential"):
+ async for ev in wf.run("checkpoint sequential", stream=True):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
@@ -240,7 +241,7 @@ async def test_sequential_checkpoint_resume_round_trip() -> None:
wf_resume = SequentialBuilder().participants(list(resumed_agents)).with_checkpointing(storage).build()
resumed_output: list[ChatMessage] | None = None
- async for ev in wf_resume.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id):
+ async for ev in wf_resume.run(checkpoint_id=resume_checkpoint.checkpoint_id, stream=True):
if isinstance(ev, WorkflowOutputEvent):
resumed_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
@@ -262,7 +263,7 @@ async def test_sequential_checkpoint_runtime_only() -> None:
wf = SequentialBuilder().participants(list(agents)).build()
baseline_output: list[ChatMessage] | None = None
- async for ev in wf.run_stream("runtime checkpoint test", checkpoint_storage=storage):
+ async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
@@ -283,7 +284,9 @@ async def test_sequential_checkpoint_runtime_only() -> None:
wf_resume = SequentialBuilder().participants(list(resumed_agents)).build()
resumed_output: list[ChatMessage] | None = None
- async for ev in wf_resume.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage):
+ async for ev in wf_resume.run(
+ checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage, stream=True
+ ):
if isinstance(ev, WorkflowOutputEvent):
resumed_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
@@ -311,7 +314,7 @@ async def test_sequential_checkpoint_runtime_overrides_buildtime() -> None:
wf = SequentialBuilder().participants(list(agents)).with_checkpointing(buildtime_storage).build()
baseline_output: list[ChatMessage] | None = None
- async for ev in wf.run_stream("override test", checkpoint_storage=runtime_storage):
+ async for ev in wf.run("override test", checkpoint_storage=runtime_storage, stream=True):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
@@ -339,7 +342,7 @@ def create_agent2() -> _EchoAgent:
wf = SequentialBuilder().register_participants([create_agent1, create_agent2]).with_checkpointing(storage).build()
baseline_output: list[ChatMessage] | None = None
- async for ev in wf.run_stream("checkpoint with factories"):
+ async for ev in wf.run("checkpoint with factories", stream=True):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = ev.data
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
@@ -361,7 +364,7 @@ def create_agent2() -> _EchoAgent:
)
resumed_output: list[ChatMessage] | None = None
- async for ev in wf_resume.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id):
+ async for ev in wf_resume.run(checkpoint_id=resume_checkpoint.checkpoint_id, stream=True):
if isinstance(ev, WorkflowOutputEvent):
resumed_output = ev.data
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
@@ -397,7 +400,7 @@ def create_agent() -> _EchoAgent:
# Run the workflow to ensure it works
completed = False
output: list[ChatMessage] | None = None
- async for ev in wf.run_stream("test factories timing"):
+ async for ev in wf.run("test factories timing", stream=True):
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
completed = True
elif isinstance(ev, WorkflowOutputEvent):
diff --git a/python/packages/purview/agent_framework_purview/_middleware.py b/python/packages/purview/agent_framework_purview/_middleware.py
index a0cce1bd55..2aabd5a57b 100644
--- a/python/packages/purview/agent_framework_purview/_middleware.py
+++ b/python/packages/purview/agent_framework_purview/_middleware.py
@@ -2,7 +2,7 @@
from collections.abc import Awaitable, Callable
-from agent_framework import AgentMiddleware, AgentRunContext, ChatContext, ChatMiddleware
+from agent_framework import AgentMiddleware, AgentRunContext, ChatContext, ChatMiddleware, MiddlewareTermination
from agent_framework._logging import get_logger
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
@@ -60,10 +60,11 @@ async def process(
from agent_framework import AgentResponse, ChatMessage
context.result = AgentResponse(
- messages=[ChatMessage("system", [self._settings.blocked_prompt_message])]
+ messages=[ChatMessage(role="system", text=self._settings.blocked_prompt_message)]
)
- context.terminate = True
- return
+ raise MiddlewareTermination
+ except MiddlewareTermination:
+ raise
except PurviewPaymentRequiredError as ex:
logger.error(f"Purview payment required error in policy pre-check: {ex}")
if not self._settings.ignore_payment_required:
@@ -78,7 +79,7 @@ async def process(
try:
# Post (response) check only if we have a normal AgentResponse
# Use the same user_id from the request for the response evaluation
- if context.result and not context.is_streaming:
+ if context.result and not context.stream:
should_block_response, _ = await self._processor.process_messages(
context.result.messages, # type: ignore[union-attr]
Activity.UPLOAD_TEXT,
@@ -88,7 +89,7 @@ async def process(
from agent_framework import AgentResponse, ChatMessage
context.result = AgentResponse(
- messages=[ChatMessage("system", [self._settings.blocked_response_message])]
+ messages=[ChatMessage(role="system", text=self._settings.blocked_response_message)]
)
else:
# Streaming responses are not supported for post-checks
@@ -149,10 +150,11 @@ async def process(
if should_block_prompt:
from agent_framework import ChatMessage, ChatResponse
- blocked_message = ChatMessage("system", [self._settings.blocked_prompt_message])
+ blocked_message = ChatMessage(role="system", text=self._settings.blocked_prompt_message)
context.result = ChatResponse(messages=[blocked_message])
- context.terminate = True
- return
+ raise MiddlewareTermination
+ except MiddlewareTermination:
+ raise
except PurviewPaymentRequiredError as ex:
logger.error(f"Purview payment required error in policy pre-check: {ex}")
if not self._settings.ignore_payment_required:
@@ -167,7 +169,7 @@ async def process(
try:
# Post (response) evaluation only if non-streaming and we have messages result shape
# Use the same user_id from the request for the response evaluation
- if context.result and not context.is_streaming:
+ if context.result and not context.stream:
result_obj = context.result
messages = getattr(result_obj, "messages", None)
if messages:
@@ -177,7 +179,7 @@ async def process(
if should_block_response:
from agent_framework import ChatMessage, ChatResponse
- blocked_message = ChatMessage("system", [self._settings.blocked_response_message])
+ blocked_message = ChatMessage(role="system", text=self._settings.blocked_response_message)
context.result = ChatResponse(messages=[blocked_message])
else:
logger.debug("Streaming responses are not supported for Purview policy post-checks")
diff --git a/python/packages/purview/tests/test_chat_middleware.py b/python/packages/purview/tests/test_chat_middleware.py
index 763a54ac67..d42c5a85a9 100644
--- a/python/packages/purview/tests/test_chat_middleware.py
+++ b/python/packages/purview/tests/test_chat_middleware.py
@@ -5,7 +5,7 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
-from agent_framework import ChatContext, ChatMessage
+from agent_framework import ChatContext, ChatMessage, MiddlewareTermination
from azure.core.credentials import AccessToken
from agent_framework_purview import PurviewChatPolicyMiddleware, PurviewSettings
@@ -36,7 +36,9 @@ def chat_context(self) -> ChatContext:
chat_client = DummyChatClient()
chat_options = MagicMock()
chat_options.model = "test-model"
- return ChatContext(chat_client=chat_client, messages=[ChatMessage("user", ["Hello"])], options=chat_options)
+ return ChatContext(
+ chat_client=chat_client, messages=[ChatMessage(role="user", text="Hello")], options=chat_options
+ )
async def test_initialization(self, middleware: PurviewChatPolicyMiddleware) -> None:
assert middleware._client is not None
@@ -54,7 +56,7 @@ async def mock_next(ctx: ChatContext) -> None:
class Result:
def __init__(self):
- self.messages = [ChatMessage("assistant", ["Hi there"])]
+ self.messages = [ChatMessage(role="assistant", text="Hi there")]
ctx.result = Result()
@@ -69,8 +71,8 @@ async def test_blocks_prompt(self, middleware: PurviewChatPolicyMiddleware, chat
async def mock_next(ctx: ChatContext) -> None: # should not run
raise AssertionError("next should not be called when prompt blocked")
- await middleware.process(chat_context, mock_next)
- assert chat_context.terminate
+ with pytest.raises(MiddlewareTermination):
+ await middleware.process(chat_context, mock_next)
assert chat_context.result
assert hasattr(chat_context.result, "messages")
msg = chat_context.result.messages[0]
@@ -90,7 +92,7 @@ async def side_effect(messages, activity, user_id=None):
async def mock_next(ctx: ChatContext) -> None:
class Result:
def __init__(self):
- self.messages = [ChatMessage("assistant", ["Sensitive output"])] # pragma: no cover
+ self.messages = [ChatMessage(role="assistant", text="Sensitive output")] # pragma: no cover
ctx.result = Result()
@@ -107,9 +109,9 @@ async def test_streaming_skips_post_check(self, middleware: PurviewChatPolicyMid
chat_options.model = "test-model"
streaming_context = ChatContext(
chat_client=chat_client,
- messages=[ChatMessage("user", ["Hello"])],
+ messages=[ChatMessage(role="user", text="Hello")],
options=chat_options,
- is_streaming=True,
+ stream=True,
)
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc:
@@ -139,7 +141,7 @@ async def mock_process_messages(*args, **kwargs):
async def mock_next(ctx: ChatContext) -> None:
result = MagicMock()
- result.messages = [ChatMessage("assistant", ["Response"])]
+ result.messages = [ChatMessage(role="assistant", text="Response")]
ctx.result = result
await middleware.process(chat_context, mock_next)
@@ -163,7 +165,7 @@ async def mock_process_messages(messages, activity, user_id=None):
async def mock_next(ctx: ChatContext) -> None:
result = MagicMock()
- result.messages = [ChatMessage("assistant", ["Response"])]
+ result.messages = [ChatMessage(role="assistant", text="Response")]
ctx.result = result
await middleware.process(chat_context, mock_next)
@@ -186,7 +188,9 @@ async def test_chat_middleware_handles_payment_required_pre_check(self, mock_cre
chat_client = DummyChatClient()
chat_options = MagicMock()
chat_options.model = "test-model"
- context = ChatContext(chat_client=chat_client, messages=[ChatMessage("user", ["Hello"])], options=chat_options)
+ context = ChatContext(
+ chat_client=chat_client, messages=[ChatMessage(role="user", text="Hello")], options=chat_options
+ )
async def mock_process_messages(*args, **kwargs):
raise PurviewPaymentRequiredError("Payment required")
@@ -210,7 +214,9 @@ async def test_chat_middleware_handles_payment_required_post_check(self, mock_cr
chat_client = DummyChatClient()
chat_options = MagicMock()
chat_options.model = "test-model"
- context = ChatContext(chat_client=chat_client, messages=[ChatMessage("user", ["Hello"])], options=chat_options)
+ context = ChatContext(
+ chat_client=chat_client, messages=[ChatMessage(role="user", text="Hello")], options=chat_options
+ )
call_count = 0
@@ -225,7 +231,7 @@ async def side_effect(*args, **kwargs):
async def mock_next(ctx: ChatContext) -> None:
result = MagicMock()
- result.messages = [ChatMessage("assistant", ["OK"])]
+ result.messages = [ChatMessage(role="assistant", text="OK")]
ctx.result = result
with pytest.raises(PurviewPaymentRequiredError):
@@ -241,7 +247,9 @@ async def test_chat_middleware_ignores_payment_required_when_configured(self, mo
chat_client = DummyChatClient()
chat_options = MagicMock()
chat_options.model = "test-model"
- context = ChatContext(chat_client=chat_client, messages=[ChatMessage("user", ["Hello"])], options=chat_options)
+ context = ChatContext(
+ chat_client=chat_client, messages=[ChatMessage(role="user", text="Hello")], options=chat_options
+ )
async def mock_process_messages(*args, **kwargs):
raise PurviewPaymentRequiredError("Payment required")
@@ -250,7 +258,7 @@ async def mock_process_messages(*args, **kwargs):
async def mock_next(ctx: ChatContext) -> None:
result = MagicMock()
- result.messages = [ChatMessage("assistant", ["Response"])]
+ result.messages = [ChatMessage(role="assistant", text="Response")]
context.result = result
# Should not raise, just log
@@ -281,7 +289,9 @@ async def test_chat_middleware_with_ignore_exceptions(self, mock_credential: Asy
chat_client = DummyChatClient()
chat_options = MagicMock()
chat_options.model = "test-model"
- context = ChatContext(chat_client=chat_client, messages=[ChatMessage("user", ["Hello"])], options=chat_options)
+ context = ChatContext(
+ chat_client=chat_client, messages=[ChatMessage(role="user", text="Hello")], options=chat_options
+ )
async def mock_process_messages(*args, **kwargs):
raise ValueError("Some error")
@@ -290,7 +300,7 @@ async def mock_process_messages(*args, **kwargs):
async def mock_next(ctx: ChatContext) -> None:
result = MagicMock()
- result.messages = [ChatMessage("assistant", ["Response"])]
+ result.messages = [ChatMessage(role="assistant", text="Response")]
context.result = result
# Should not raise, just log
@@ -308,7 +318,9 @@ async def test_chat_middleware_raises_on_pre_check_exception_when_ignore_excepti
chat_client = DummyChatClient()
chat_options = MagicMock()
chat_options.model = "test-model"
- context = ChatContext(chat_client=chat_client, messages=[ChatMessage("user", ["Hello"])], options=chat_options)
+ context = ChatContext(
+ chat_client=chat_client, messages=[ChatMessage(role="user", text="Hello")], options=chat_options
+ )
with patch.object(middleware._processor, "process_messages", side_effect=ValueError("boom")):
@@ -328,7 +340,9 @@ async def test_chat_middleware_raises_on_post_check_exception_when_ignore_except
chat_client = DummyChatClient()
chat_options = MagicMock()
chat_options.model = "test-model"
- context = ChatContext(chat_client=chat_client, messages=[ChatMessage("user", ["Hello"])], options=chat_options)
+ context = ChatContext(
+ chat_client=chat_client, messages=[ChatMessage(role="user", text="Hello")], options=chat_options
+ )
call_count = 0
@@ -343,7 +357,7 @@ async def side_effect(*args, **kwargs):
async def mock_next(ctx: ChatContext) -> None:
result = MagicMock()
- result.messages = [ChatMessage("assistant", ["OK"])]
+ result.messages = [ChatMessage(role="assistant", text="OK")]
ctx.result = result
with pytest.raises(ValueError, match="post"):
diff --git a/python/packages/purview/tests/test_middleware.py b/python/packages/purview/tests/test_middleware.py
index 32f712b0b9..7c9edacd1a 100644
--- a/python/packages/purview/tests/test_middleware.py
+++ b/python/packages/purview/tests/test_middleware.py
@@ -5,7 +5,7 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
-from agent_framework import AgentResponse, AgentRunContext, ChatMessage
+from agent_framework import AgentResponse, AgentRunContext, ChatMessage, MiddlewareTermination
from azure.core.credentials import AccessToken
from agent_framework_purview import PurviewPolicyMiddleware, PurviewSettings
@@ -49,7 +49,7 @@ async def test_middleware_allows_clean_prompt(
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test middleware allows prompt that passes policy check."""
- context = AgentRunContext(agent=mock_agent, messages=[ChatMessage("user", ["Hello, how are you?"])])
+ context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Hello, how are you?")])
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")):
next_called = False
@@ -57,19 +57,18 @@ async def test_middleware_allows_clean_prompt(
async def mock_next(ctx: AgentRunContext) -> None:
nonlocal next_called
next_called = True
- ctx.result = AgentResponse(messages=[ChatMessage("assistant", ["I'm good, thanks!"])])
+ ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="I'm good, thanks!")])
await middleware.process(context, mock_next)
assert next_called
assert context.result is not None
- assert not context.terminate
async def test_middleware_blocks_prompt_on_policy_violation(
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test middleware blocks prompt that violates policy."""
- context = AgentRunContext(agent=mock_agent, messages=[ChatMessage("user", ["Sensitive information"])])
+ context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Sensitive information")])
with patch.object(middleware._processor, "process_messages", return_value=(True, "user-123")):
next_called = False
@@ -78,18 +77,18 @@ async def mock_next(ctx: AgentRunContext) -> None:
nonlocal next_called
next_called = True
- await middleware.process(context, mock_next)
+ with pytest.raises(MiddlewareTermination):
+ await middleware.process(context, mock_next)
assert not next_called
assert context.result is not None
- assert context.terminate
assert len(context.result.messages) == 1
assert context.result.messages[0].role == "system"
assert "blocked by policy" in context.result.messages[0].text.lower()
async def test_middleware_checks_response(self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock) -> None:
"""Test middleware checks agent response for policy violations."""
- context = AgentRunContext(agent=mock_agent, messages=[ChatMessage("user", ["Hello"])])
+ context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Hello")])
call_count = 0
@@ -102,7 +101,9 @@ async def mock_process_messages(messages, activity, user_id=None):
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx: AgentRunContext) -> None:
- ctx.result = AgentResponse(messages=[ChatMessage("assistant", ["Here's some sensitive information"])])
+ ctx.result = AgentResponse(
+ messages=[ChatMessage(role="assistant", text="Here's some sensitive information")]
+ )
await middleware.process(context, mock_next)
@@ -119,7 +120,7 @@ async def test_middleware_handles_result_without_messages(
# Set ignore_exceptions to True so AttributeError is caught and logged
middleware._settings.ignore_exceptions = True
- context = AgentRunContext(agent=mock_agent, messages=[ChatMessage("user", ["Hello"])])
+ context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Hello")])
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")):
@@ -136,12 +137,12 @@ async def test_middleware_processor_receives_correct_activity(
"""Test middleware passes correct activity type to processor."""
from agent_framework_purview._models import Activity
- context = AgentRunContext(agent=mock_agent, messages=[ChatMessage("user", ["Test"])])
+ context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Test")])
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_process:
async def mock_next(ctx: AgentRunContext) -> None:
- ctx.result = AgentResponse(messages=[ChatMessage("assistant", ["Response"])])
+ ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="Response")])
await middleware.process(context, mock_next)
@@ -153,13 +154,13 @@ async def test_middleware_streaming_skips_post_check(
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test that streaming results skip post-check evaluation."""
- context = AgentRunContext(agent=mock_agent, messages=[ChatMessage("user", ["Hello"])])
- context.is_streaming = True
+ context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Hello")])
+ context.stream = True
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc:
async def mock_next(ctx: AgentRunContext) -> None:
- ctx.result = AgentResponse(messages=[ChatMessage("assistant", ["streaming"])])
+ ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="streaming")])
await middleware.process(context, mock_next)
@@ -171,7 +172,7 @@ async def test_middleware_payment_required_in_pre_check_raises_by_default(
"""Test that 402 in pre-check is raised when ignore_payment_required=False."""
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
- context = AgentRunContext(agent=mock_agent, messages=[ChatMessage("user", ["Hello"])])
+ context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Hello")])
with patch.object(
middleware._processor,
@@ -191,7 +192,7 @@ async def test_middleware_payment_required_in_post_check_raises_by_default(
"""Test that 402 in post-check is raised when ignore_payment_required=False."""
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
- context = AgentRunContext(agent=mock_agent, messages=[ChatMessage("user", ["Hello"])])
+ context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Hello")])
call_count = 0
@@ -205,7 +206,7 @@ async def side_effect(*args, **kwargs):
with patch.object(middleware._processor, "process_messages", side_effect=side_effect):
async def mock_next(ctx: AgentRunContext) -> None:
- ctx.result = AgentResponse(messages=[ChatMessage("assistant", ["OK"])])
+ ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="OK")])
with pytest.raises(PurviewPaymentRequiredError):
await middleware.process(context, mock_next)
@@ -216,7 +217,7 @@ async def test_middleware_post_check_exception_raises_when_ignore_exceptions_fal
"""Test that post-check exceptions are propagated when ignore_exceptions=False."""
middleware._settings.ignore_exceptions = False
- context = AgentRunContext(agent=mock_agent, messages=[ChatMessage("user", ["Hello"])])
+ context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Hello")])
call_count = 0
@@ -230,7 +231,7 @@ async def side_effect(*args, **kwargs):
with patch.object(middleware._processor, "process_messages", side_effect=side_effect):
async def mock_next(ctx: AgentRunContext) -> None:
- ctx.result = AgentResponse(messages=[ChatMessage("assistant", ["OK"])])
+ ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="OK")])
with pytest.raises(ValueError, match="Post-check blew up"):
await middleware.process(context, mock_next)
@@ -242,21 +243,19 @@ async def test_middleware_handles_pre_check_exception(
# Set ignore_exceptions to True
middleware._settings.ignore_exceptions = True
- context = AgentRunContext(agent=mock_agent, messages=[ChatMessage("user", ["Test"])])
+ context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Test")])
with patch.object(
middleware._processor, "process_messages", side_effect=Exception("Pre-check error")
) as mock_process:
async def mock_next(ctx: AgentRunContext) -> None:
- ctx.result = AgentResponse(messages=[ChatMessage("assistant", ["Response"])])
+ ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="Response")])
await middleware.process(context, mock_next)
# Should have been called twice (pre-check raises, then post-check also raises)
assert mock_process.call_count == 2
- # Context should not be terminated
- assert not context.terminate
# Result should be set by mock_next
assert context.result is not None
@@ -267,7 +266,7 @@ async def test_middleware_handles_post_check_exception(
# Set ignore_exceptions to True
middleware._settings.ignore_exceptions = True
- context = AgentRunContext(agent=mock_agent, messages=[ChatMessage("user", ["Test"])])
+ context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Test")])
call_count = 0
@@ -281,7 +280,7 @@ async def mock_process_messages(*args, **kwargs):
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx: AgentRunContext) -> None:
- ctx.result = AgentResponse(messages=[ChatMessage("assistant", ["Response"])])
+ ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="Response")])
await middleware.process(context, mock_next)
@@ -298,7 +297,7 @@ async def test_middleware_with_ignore_exceptions_true(self, mock_credential: Asy
mock_agent = MagicMock()
mock_agent.name = "test-agent"
- context = AgentRunContext(agent=mock_agent, messages=[ChatMessage("user", ["Test"])])
+ context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Test")])
# Mock processor to raise an exception
async def mock_process_messages(*args, **kwargs):
@@ -307,7 +306,7 @@ async def mock_process_messages(*args, **kwargs):
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx):
- ctx.result = AgentResponse(messages=[ChatMessage("assistant", ["Response"])])
+ ctx.result = AgentResponse(messages=[ChatMessage(role="assistant", text="Response")])
# Should not raise, just log
await middleware.process(context, mock_next)
@@ -322,7 +321,7 @@ async def test_middleware_with_ignore_exceptions_false(self, mock_credential: As
mock_agent = MagicMock()
mock_agent.name = "test-agent"
- context = AgentRunContext(agent=mock_agent, messages=[ChatMessage("user", ["Test"])])
+ context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role="user", text="Test")])
# Mock processor to raise an exception
async def mock_process_messages(*args, **kwargs):
diff --git a/python/packages/purview/tests/test_processor.py b/python/packages/purview/tests/test_processor.py
index 3dfd78d981..f122c6e059 100644
--- a/python/packages/purview/tests/test_processor.py
+++ b/python/packages/purview/tests/test_processor.py
@@ -83,8 +83,8 @@ async def test_processor_initialization(
async def test_process_messages_with_defaults(self, processor: ScopedContentProcessor) -> None:
"""Test process_messages with settings that have defaults."""
messages = [
- ChatMessage("user", ["Hello"]),
- ChatMessage("assistant", ["Hi there"]),
+ ChatMessage(role="user", text="Hello"),
+ ChatMessage(role="assistant", text="Hi there"),
]
with patch.object(processor, "_map_messages", return_value=([], None)) as mock_map:
@@ -98,7 +98,7 @@ async def test_process_messages_blocks_content(
self, processor: ScopedContentProcessor, process_content_request_factory
) -> None:
"""Test process_messages returns True when content should be blocked."""
- messages = [ChatMessage("user", ["Sensitive content"])]
+ messages = [ChatMessage(role="user", text="Sensitive content")]
mock_request = process_content_request_factory("Sensitive content")
@@ -139,7 +139,7 @@ async def test_map_messages_without_defaults_gets_token_info(self, mock_client:
"""Test _map_messages gets token info when settings lack some defaults."""
settings = PurviewSettings(app_name="Test App", tenant_id="12345678-1234-1234-1234-123456789012")
processor = ScopedContentProcessor(mock_client, settings)
- messages = [ChatMessage("user", ["Test"], message_id="msg-123")]
+ messages = [ChatMessage(role="user", text="Test", message_id="msg-123")]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
@@ -156,7 +156,7 @@ async def test_map_messages_raises_on_missing_tenant_id(self, mock_client: Async
return_value={"user_id": "test-user", "client_id": "test-client"}
)
- messages = [ChatMessage("user", ["Test"], message_id="msg-123")]
+ messages = [ChatMessage(role="user", text="Test", message_id="msg-123")]
with pytest.raises(ValueError, match="Tenant id required"):
await processor._map_messages(messages, Activity.UPLOAD_TEXT)
@@ -355,7 +355,7 @@ async def test_map_messages_with_provided_user_id_fallback(self, mock_client: As
)
processor = ScopedContentProcessor(mock_client, settings)
- messages = [ChatMessage("user", ["Test message"])]
+ messages = [ChatMessage(role="user", text="Test message")]
requests, user_id = await processor._map_messages(
messages, Activity.UPLOAD_TEXT, provided_user_id="32345678-1234-1234-1234-123456789012"
@@ -376,7 +376,7 @@ async def test_map_messages_returns_empty_when_no_user_id(self, mock_client: Asy
)
processor = ScopedContentProcessor(mock_client, settings)
- messages = [ChatMessage("user", ["Test message"])]
+ messages = [ChatMessage(role="user", text="Test message")]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
@@ -479,7 +479,7 @@ async def test_user_id_from_token_when_no_other_source(self, mock_client: AsyncM
settings = PurviewSettings(app_name="Test App") # No tenant_id or app_location
processor = ScopedContentProcessor(mock_client, settings)
- messages = [ChatMessage("user", ["Test"])]
+ messages = [ChatMessage(role="user", text="Test")]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
@@ -550,7 +550,7 @@ async def test_provided_user_id_used_as_last_resort(
"""Test provided_user_id parameter is used as last resort."""
processor = ScopedContentProcessor(mock_client, settings)
- messages = [ChatMessage("user", ["Test"])]
+ messages = [ChatMessage(role="user", text="Test")]
requests, user_id = await processor._map_messages(
messages, Activity.UPLOAD_TEXT, provided_user_id="44444444-4444-4444-4444-444444444444"
@@ -562,7 +562,7 @@ async def test_invalid_provided_user_id_ignored(self, mock_client: AsyncMock, se
"""Test invalid provided_user_id is ignored."""
processor = ScopedContentProcessor(mock_client, settings)
- messages = [ChatMessage("user", ["Test"])]
+ messages = [ChatMessage(role="user", text="Test")]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT, provided_user_id="not-a-guid")
@@ -577,8 +577,8 @@ async def test_multiple_messages_same_user_id(self, mock_client: AsyncMock, sett
ChatMessage(
role="user", text="First", additional_properties={"user_id": "55555555-5555-5555-5555-555555555555"}
),
- ChatMessage("assistant", ["Response"]),
- ChatMessage("user", ["Second"]),
+ ChatMessage(role="assistant", text="Response"),
+ ChatMessage(role="user", text="Second"),
]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
@@ -594,7 +594,7 @@ async def test_first_valid_user_id_in_messages_is_used(
processor = ScopedContentProcessor(mock_client, settings)
messages = [
- ChatMessage("user", ["First"], author_name="Not a GUID"),
+ ChatMessage(role="user", text="First", author_name="Not a GUID"),
ChatMessage(
role="assistant",
text="Response",
@@ -654,7 +654,7 @@ async def test_protection_scopes_cached_on_first_call(
scope_identifier="scope-123", scopes=[]
)
- messages = [ChatMessage("user", ["Test"])]
+ messages = [ChatMessage(role="user", text="Test")]
await processor.process_messages(messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012")
@@ -676,7 +676,7 @@ async def test_payment_required_exception_cached_at_tenant_level(
mock_client.get_protection_scopes.side_effect = PurviewPaymentRequiredError("Payment required")
- messages = [ChatMessage("user", ["Test"])]
+ messages = [ChatMessage(role="user", text="Test")]
with pytest.raises(PurviewPaymentRequiredError):
await processor.process_messages(
diff --git a/python/packages/purview/tests/test_client.py b/python/packages/purview/tests/test_purview_client.py
similarity index 100%
rename from python/packages/purview/tests/test_client.py
rename to python/packages/purview/tests/test_purview_client.py
diff --git a/python/packages/redis/agent_framework_redis/_chat_message_store.py b/python/packages/redis/agent_framework_redis/_chat_message_store.py
index a68bc9f1d8..4b50c63571 100644
--- a/python/packages/redis/agent_framework_redis/_chat_message_store.py
+++ b/python/packages/redis/agent_framework_redis/_chat_message_store.py
@@ -225,7 +225,7 @@ async def add_messages(self, messages: Sequence[ChatMessage]) -> None:
Example:
.. code-block:: python
- messages = [ChatMessage("user", ["Hello"]), ChatMessage("assistant", ["Hi there!"])]
+ messages = [ChatMessage(role="user", text="Hello"), ChatMessage(role="assistant", text="Hi there!")]
await store.add_messages(messages)
"""
if not messages:
diff --git a/python/packages/redis/agent_framework_redis/_provider.py b/python/packages/redis/agent_framework_redis/_provider.py
index ce3090b92a..98c1195600 100644
--- a/python/packages/redis/agent_framework_redis/_provider.py
+++ b/python/packages/redis/agent_framework_redis/_provider.py
@@ -541,7 +541,7 @@ async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], *
)
return Context(
- messages=[ChatMessage("user", [f"{self.context_prompt}\n{line_separated_memories}"])]
+ messages=[ChatMessage(role="user", text=f"{self.context_prompt}\n{line_separated_memories}")]
if line_separated_memories
else None
)
diff --git a/python/packages/redis/tests/test_redis_chat_message_store.py b/python/packages/redis/tests/test_redis_chat_message_store.py
index 0bbb200dfe..152d99fdf1 100644
--- a/python/packages/redis/tests/test_redis_chat_message_store.py
+++ b/python/packages/redis/tests/test_redis_chat_message_store.py
@@ -19,9 +19,9 @@ class TestRedisChatMessageStore:
def sample_messages(self):
"""Sample chat messages for testing."""
return [
- ChatMessage("user", ["Hello"], message_id="msg1"),
- ChatMessage("assistant", ["Hi there!"], message_id="msg2"),
- ChatMessage("user", ["How are you?"], message_id="msg3"),
+ ChatMessage(role="user", text="Hello", message_id="msg1"),
+ ChatMessage(role="assistant", text="Hi there!", message_id="msg2"),
+ ChatMessage(role="user", text="How are you?", message_id="msg3"),
]
@pytest.fixture
@@ -250,7 +250,7 @@ async def test_add_messages_with_max_limit(self, mock_redis_client):
store = RedisChatMessageStore(redis_url="redis://localhost:6379", thread_id="test123", max_messages=3)
store._redis_client = mock_redis_client
- message = ChatMessage("user", ["Test"])
+ message = ChatMessage(role="user", text="Test")
await store.add_messages([message])
# Should trim after adding to keep only last 3 messages
@@ -269,8 +269,8 @@ async def test_list_messages_with_data(self, redis_store, mock_redis_client, sam
"""Test listing messages with data in Redis."""
# Create proper serialized messages using the actual serialization method
test_messages = [
- ChatMessage("user", ["Hello"], message_id="msg1"),
- ChatMessage("assistant", ["Hi there!"], message_id="msg2"),
+ ChatMessage(role="user", text="Hello", message_id="msg1"),
+ ChatMessage(role="assistant", text="Hi there!", message_id="msg2"),
]
serialized_messages = [redis_store._serialize_message(msg) for msg in test_messages]
mock_redis_client.lrange.return_value = serialized_messages
@@ -444,7 +444,7 @@ async def test_redis_connection_error_handling(self):
store = RedisChatMessageStore(redis_url="redis://localhost:6379", thread_id="test123")
store._redis_client = mock_client
- message = ChatMessage("user", ["Test"])
+ message = ChatMessage(role="user", text="Test")
# Should propagate Redis connection errors
with pytest.raises(Exception, match="Connection failed"):
@@ -485,7 +485,7 @@ async def test_setitem(self, redis_store, mock_redis_client, sample_messages):
mock_redis_client.llen.return_value = 2
mock_redis_client.lset = AsyncMock()
- new_message = ChatMessage("user", ["Updated message"])
+ new_message = ChatMessage(role="user", text="Updated message")
await redis_store.setitem(0, new_message)
mock_redis_client.lset.assert_called_once()
@@ -497,13 +497,13 @@ async def test_setitem_index_error(self, redis_store, mock_redis_client):
"""Test setitem raises IndexError for invalid index."""
mock_redis_client.llen.return_value = 0
- new_message = ChatMessage("user", ["Test"])
+ new_message = ChatMessage(role="user", text="Test")
with pytest.raises(IndexError):
await redis_store.setitem(0, new_message)
async def test_append(self, redis_store, mock_redis_client):
"""Test append method delegates to add_messages."""
- message = ChatMessage("user", ["Appended message"])
+ message = ChatMessage(role="user", text="Appended message")
await redis_store.append(message)
# Should call pipeline operations via add_messages
diff --git a/python/packages/redis/tests/test_redis_provider.py b/python/packages/redis/tests/test_redis_provider.py
index e5db9d25fd..41ce7b37b8 100644
--- a/python/packages/redis/tests/test_redis_provider.py
+++ b/python/packages/redis/tests/test_redis_provider.py
@@ -115,16 +115,16 @@ class TestRedisProviderMessages:
@pytest.fixture
def sample_messages(self) -> list[ChatMessage]:
return [
- ChatMessage("user", ["Hello, how are you?"]),
- ChatMessage("assistant", ["I'm doing well, thank you!"]),
- ChatMessage("system", ["You are a helpful assistant"]),
+ ChatMessage(role="user", text="Hello, how are you?"),
+ ChatMessage(role="assistant", text="I'm doing well, thank you!"),
+ ChatMessage(role="system", text="You are a helpful assistant"),
]
# Writes require at least one scoping filter to avoid unbounded operations
async def test_messages_adding_requires_filters(self, patch_index_from_dict): # noqa: ARG002
provider = RedisProvider()
with pytest.raises(ServiceInitializationError):
- await provider.invoked("thread123", ChatMessage("user", ["Hello"]))
+ await provider.invoked("thread123", ChatMessage(role="user", text="Hello"))
# Captures the per-operation thread id when provided
async def test_thread_created_sets_per_operation_id(self, patch_index_from_dict): # noqa: ARG002
@@ -157,7 +157,7 @@ class TestRedisProviderModelInvoking:
async def test_model_invoking_requires_filters(self, patch_index_from_dict): # noqa: ARG002
provider = RedisProvider()
with pytest.raises(ServiceInitializationError):
- await provider.invoking(ChatMessage("user", ["Hi"]))
+ await provider.invoking(ChatMessage(role="user", text="Hi"))
# Ensures text-only search path is used and context is composed from hits
async def test_textquery_path_and_context_contents(
@@ -168,7 +168,7 @@ async def test_textquery_path_and_context_contents(
provider = RedisProvider(user_id="u1")
# Act
- ctx = await provider.invoking([ChatMessage("user", ["q1"])])
+ ctx = await provider.invoking([ChatMessage(role="user", text="q1")])
# Assert: TextQuery used (not HybridQuery), filter_expression included
assert patch_queries["TextQuery"].call_count == 1
@@ -190,7 +190,7 @@ async def test_model_invoking_empty_results_returns_empty_context(
): # noqa: ARG002
mock_index.query = AsyncMock(return_value=[])
provider = RedisProvider(user_id="u1")
- ctx = await provider.invoking([ChatMessage("user", ["any"])])
+ ctx = await provider.invoking([ChatMessage(role="user", text="any")])
assert ctx.messages == []
# Ensures hybrid vector-text search is used when a vectorizer and vector field are configured
@@ -198,7 +198,7 @@ async def test_hybridquery_path_with_vectorizer(self, mock_index: AsyncMock, pat
mock_index.query = AsyncMock(return_value=[{"content": "Hit"}])
provider = RedisProvider(user_id="u1", redis_vectorizer=CUSTOM_VECTORIZER, vector_field_name="vec")
- ctx = await provider.invoking([ChatMessage("user", ["hello"])])
+ ctx = await provider.invoking([ChatMessage(role="user", text="hello")])
# Assert: HybridQuery used with vector and vector field
assert patch_queries["HybridQuery"].call_count == 1
@@ -240,9 +240,9 @@ async def test_messages_adding_adds_partition_defaults_and_roles(
)
msgs = [
- ChatMessage("user", ["u"]),
- ChatMessage("assistant", ["a"]),
- ChatMessage("system", ["s"]),
+ ChatMessage(role="user", text="u"),
+ ChatMessage(role="assistant", text="a"),
+ ChatMessage(role="system", text="s"),
]
await provider.invoked(msgs)
@@ -265,8 +265,8 @@ async def test_messages_adding_ignores_blank_and_disallowed_roles(
): # noqa: ARG002
provider = RedisProvider(user_id="u1", scope_to_per_operation_thread_id=True)
msgs = [
- ChatMessage("user", [" "]),
- ChatMessage("tool", ["tool output"]),
+ ChatMessage(role="user", text=" "),
+ ChatMessage(role="tool", text="tool output"),
]
await provider.invoked(msgs)
# No valid messages -> no load
@@ -279,8 +279,8 @@ async def test_messages_adding_triggers_index_create_once_when_drop_true(
self, mock_index: AsyncMock, patch_index_from_dict
): # noqa: ARG002
provider = RedisProvider(user_id="u1")
- await provider.invoked(ChatMessage("user", ["m1"]))
- await provider.invoked(ChatMessage("user", ["m2"]))
+ await provider.invoked(ChatMessage(role="user", text="m1"))
+ await provider.invoked(ChatMessage(role="user", text="m2"))
# create only on first call
assert mock_index.create.await_count == 1
@@ -291,7 +291,7 @@ async def test_model_invoking_triggers_create_when_drop_false_and_not_exists(
mock_index.exists = AsyncMock(return_value=False)
provider = RedisProvider(user_id="u1")
mock_index.query = AsyncMock(return_value=[{"content": "C"}])
- await provider.invoking([ChatMessage("user", ["q"])])
+ await provider.invoking([ChatMessage(role="user", text="q")])
assert mock_index.create.await_count == 1
@@ -321,7 +321,7 @@ async def test_messages_adding_populates_vector_field_when_vectorizer_present(
vector_field_name="vec",
)
- await provider.invoked(ChatMessage("user", ["hello"]))
+ await provider.invoked(ChatMessage(role="user", text="hello"))
assert mock_index.load.await_count == 1
(loaded_args, _kwargs) = mock_index.load.call_args
docs = loaded_args[0]
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 0719aec79f..844c9d09a9 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -171,13 +171,13 @@ notice-rgx = "^# Copyright \\(c\\) Microsoft\\. All rights reserved\\."
min-file-size = 1
[tool.pytest.ini_options]
-testpaths = 'packages/**/tests'
+testpaths = ['packages/**/tests', 'packages/**/ag_ui_tests']
norecursedirs = '**/lab/**'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
-timeout = 120
+timeout = 60
markers = [
"azure: marks tests as Azure provider specific",
"azure-ai: marks tests as Azure AI provider specific",
@@ -262,7 +262,7 @@ pytest --import-mode=importlib
--ignore-glob=packages/devui/**
-rs
-n logical --dist loadfile --dist worksteal
-packages/**/tests
+ packages/**/tests
"""
[tool.poe.tasks.all-tests]
@@ -272,7 +272,7 @@ pytest --import-mode=importlib
--ignore-glob=packages/devui/**
-rs
-n logical --dist loadfile --dist worksteal
-packages/**/tests
+ packages/**/tests
"""
[tool.poe.tasks.venv]
diff --git a/python/samples/README.md b/python/samples/README.md
index a2c539be02..fc64dced52 100644
--- a/python/samples/README.md
+++ b/python/samples/README.md
@@ -95,7 +95,7 @@ This directory contains samples demonstrating the capabilities of Microsoft Agen
| File | Description |
|------|-------------|
| [`getting_started/agents/custom/custom_agent.py`](./getting_started/agents/custom/custom_agent.py) | Custom Agent Implementation Example |
-| [`getting_started/agents/custom/custom_chat_client.py`](./getting_started/agents/custom/custom_chat_client.py) | Custom Chat Client Implementation Example |
+| [`getting_started/chat_client/custom_chat_client.py`](./getting_started/chat_client/custom_chat_client.py) | Custom Chat Client Implementation Example |
### Ollama
diff --git a/python/samples/autogen-migration/README.md b/python/samples/autogen-migration/README.md
index 616d3c345e..509b518f8a 100644
--- a/python/samples/autogen-migration/README.md
+++ b/python/samples/autogen-migration/README.md
@@ -52,7 +52,7 @@ python samples/autogen-migration/orchestrations/04_magentic_one.py
## Tips for Migration
- **Default behavior differences**: AutoGen's `AssistantAgent` is single-turn by default (`max_tool_iterations=1`), while AF's `ChatAgent` is multi-turn and continues tool execution automatically.
-- **Thread management**: AF agents are stateless by default. Use `agent.get_new_thread()` and pass it to `run()`/`run_stream()` to maintain conversation state, similar to AutoGen's conversation context.
+- **Thread management**: AF agents are stateless by default. Use `agent.get_new_thread()` and pass it to `run()` to maintain conversation state, similar to AutoGen's conversation context.
- **Tools**: AutoGen uses `FunctionTool` wrappers; AF uses `@tool` decorators with automatic schema inference.
- **Orchestration patterns**:
- `RoundRobinGroupChat` → `SequentialBuilder` or `WorkflowBuilder`
diff --git a/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py b/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py
index 09e7f2411a..f89891ddc7 100644
--- a/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py
+++ b/python/samples/autogen-migration/orchestrations/01_round_robin_group_chat.py
@@ -82,7 +82,7 @@ async def run_agent_framework() -> None:
# Run the workflow
print("[Agent Framework] Sequential conversation:")
current_executor = None
- async for event in workflow.run_stream("Create a brief summary about electric vehicles"):
+ async for event in workflow.run("Create a brief summary about electric vehicles", stream=True):
if isinstance(event, WorkflowOutputEvent):
# Print executor name header when switching to a new agent
if current_executor != event.executor_id:
@@ -153,7 +153,7 @@ async def check_approval(
# Run the workflow
print("[Agent Framework with Cycle] Cyclic conversation:")
current_executor = None
- async for event in workflow.run_stream("Create a brief summary about electric vehicles"):
+ async for event in workflow.run("Create a brief summary about electric vehicles", stream=True):
if isinstance(event, WorkflowOutputEvent) and isinstance(event.data, AgentResponseUpdate):
# Print executor name header when switching to a new agent
if current_executor != event.executor_id:
diff --git a/python/samples/autogen-migration/orchestrations/02_selector_group_chat.py b/python/samples/autogen-migration/orchestrations/02_selector_group_chat.py
index d9aea5a8f2..6eae117432 100644
--- a/python/samples/autogen-migration/orchestrations/02_selector_group_chat.py
+++ b/python/samples/autogen-migration/orchestrations/02_selector_group_chat.py
@@ -101,7 +101,7 @@ async def run_agent_framework() -> None:
# Run with a question that requires expert selection
print("[Agent Framework] Group chat conversation:")
current_executor = None
- async for event in workflow.run_stream("How do I connect to a PostgreSQL database using Python?"):
+ async for event in workflow.run("How do I connect to a PostgreSQL database using Python?", stream=True):
if isinstance(event, WorkflowOutputEvent) and isinstance(event.data, AgentResponseUpdate):
# Print executor name header when switching to a new agent
if current_executor != event.executor_id:
diff --git a/python/samples/autogen-migration/orchestrations/03_swarm.py b/python/samples/autogen-migration/orchestrations/03_swarm.py
index e29c2748c7..df398a96ea 100644
--- a/python/samples/autogen-migration/orchestrations/03_swarm.py
+++ b/python/samples/autogen-migration/orchestrations/03_swarm.py
@@ -161,7 +161,7 @@ async def run_agent_framework() -> None:
stream_line_open = False
pending_requests: list[RequestInfoEvent] = []
- async for event in workflow.run_stream(scripted_responses[0]):
+ async for event in workflow.run(scripted_responses[0], stream=True):
if isinstance(event, WorkflowOutputEvent) and isinstance(event.data, AgentResponseUpdate):
# Print executor name header when switching to a new agent
if current_executor != event.executor_id:
diff --git a/python/samples/autogen-migration/orchestrations/04_magentic_one.py b/python/samples/autogen-migration/orchestrations/04_magentic_one.py
index dbe6f43bc7..1fc4e88d31 100644
--- a/python/samples/autogen-migration/orchestrations/04_magentic_one.py
+++ b/python/samples/autogen-migration/orchestrations/04_magentic_one.py
@@ -112,7 +112,7 @@ async def run_agent_framework() -> None:
last_message_id: str | None = None
output_event: WorkflowOutputEvent | None = None
print("[Agent Framework] Magentic conversation:")
- async for event in workflow.run_stream("Research Python async patterns and write a simple example"):
+ async for event in workflow.run("Research Python async patterns and write a simple example", stream=True):
if isinstance(event, WorkflowOutputEvent) and isinstance(event.data, AgentResponseUpdate):
message_id = event.data.message_id
if message_id != last_message_id:
diff --git a/python/samples/autogen-migration/single_agent/03_assistant_agent_thread_and_stream.py b/python/samples/autogen-migration/single_agent/03_assistant_agent_thread_and_stream.py
index c2d79f4b86..8cb516fe85 100644
--- a/python/samples/autogen-migration/single_agent/03_assistant_agent_thread_and_stream.py
+++ b/python/samples/autogen-migration/single_agent/03_assistant_agent_thread_and_stream.py
@@ -32,7 +32,7 @@ async def run_autogen() -> None:
print("\n[AutoGen] Streaming response:")
# Stream response with Console for token streaming
- await Console(agent.run_stream(task="Count from 1 to 5"))
+ await Console(agent.run(task="Count from 1 to 5", stream=True))
async def run_agent_framework() -> None:
@@ -60,7 +60,7 @@ async def run_agent_framework() -> None:
print("\n[Agent Framework] Streaming response:")
# Stream response
print(" ", end="")
- async for chunk in agent.run_stream("Count from 1 to 5"):
+ async for chunk in agent.run("Count from 1 to 5", thread=thread, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print()
diff --git a/python/samples/autogen-migration/single_agent/04_agent_as_tool.py b/python/samples/autogen-migration/single_agent/04_agent_as_tool.py
index 014b7b8adf..52edc1eec7 100644
--- a/python/samples/autogen-migration/single_agent/04_agent_as_tool.py
+++ b/python/samples/autogen-migration/single_agent/04_agent_as_tool.py
@@ -43,7 +43,7 @@ async def run_autogen() -> None:
# Run coordinator with streaming - it will delegate to writer
print("[AutoGen]")
- await Console(coordinator.run_stream(task="Create a tagline for a coffee shop"))
+ await Console(coordinator.run(task="Create a tagline for a coffee shop", stream=True))
async def run_agent_framework() -> None:
@@ -80,7 +80,7 @@ async def run_agent_framework() -> None:
# Track accumulated function calls (they stream in incrementally)
accumulated_calls: dict[str, FunctionCallContent] = {}
- async for chunk in coordinator.run_stream("Create a tagline for a coffee shop"):
+ async for chunk in coordinator.run("Create a tagline for a coffee shop", stream=True):
# Stream text tokens
if chunk.text:
print(chunk.text, end="", flush=True)
diff --git a/python/samples/concepts/README.md b/python/samples/concepts/README.md
new file mode 100644
index 0000000000..8e3c0282fa
--- /dev/null
+++ b/python/samples/concepts/README.md
@@ -0,0 +1,10 @@
+# Concept Samples
+
+This folder contains samples that dive deep into specific Agent Framework concepts.
+
+## Samples
+
+| Sample | Description |
+|--------|-------------|
+| [response_stream.py](response_stream.py) | Deep dive into `ResponseStream` - the streaming abstraction for AI responses. Covers the four hook types (transform hooks, cleanup hooks, finalizer, result hooks), two consumption patterns (iteration vs direct finalization), and the `wrap()` API for layering streams without double-consumption. |
+| [typed_options.py](typed_options.py) | Demonstrates TypedDict-based chat options for type-safe configuration with IDE autocomplete support. |
diff --git a/python/samples/concepts/response_stream.py b/python/samples/concepts/response_stream.py
new file mode 100644
index 0000000000..98d5169760
--- /dev/null
+++ b/python/samples/concepts/response_stream.py
@@ -0,0 +1,360 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import asyncio
+from collections.abc import AsyncIterable, Sequence
+
+from agent_framework import ChatResponse, ChatResponseUpdate, Content, ResponseStream, Role
+
+"""ResponseStream: A Deep Dive
+
+This sample explores the ResponseStream class - a powerful abstraction for working with
+streaming responses in the Agent Framework.
+
+=== Why ResponseStream Exists ===
+
+When working with AI models, responses can be delivered in two ways:
+1. **Non-streaming**: Wait for the complete response, then return it all at once
+2. **Streaming**: Receive incremental updates as they're generated
+
+Streaming provides a better user experience (faster time-to-first-token, progressive rendering)
+but introduces complexity:
+- How do you process updates as they arrive?
+- How do you also get a final, complete response?
+- How do you ensure the underlying stream is only consumed once?
+- How do you add custom logic (hooks) at different stages?
+
+ResponseStream solves all these problems by wrapping an async iterable and providing:
+- Multiple consumption patterns (iteration OR direct finalization)
+- Hook points for transformation, cleanup, finalization, and result processing
+- The `wrap()` API to layer behavior without double-consuming the stream
+
+=== The Four Hook Types ===
+
+ResponseStream provides four ways to inject custom logic. All can be passed via constructor
+or added later via fluent methods:
+
+1. **Transform Hooks** (`transform_hooks=[]` or `.with_transform_hook()`)
+ - Called for EACH update as it's yielded during iteration
+ - Can transform updates before they're returned to the consumer
+ - Multiple hooks are called in order, each receiving the previous hook's output
+ - Only triggered during iteration (not when calling get_final_response directly)
+
+2. **Cleanup Hooks** (`cleanup_hooks=[]` or `.with_cleanup_hook()`)
+ - Called ONCE when iteration completes (stream fully consumed), BEFORE finalizer
+ - Used for cleanup: closing connections, releasing resources, logging
+ - Cannot modify the stream or response
+ - Triggered regardless of how the stream ends (normal completion or exception)
+
+3. **Finalizer** (`finalizer=` constructor parameter)
+ - Called ONCE when `get_final_response()` is invoked
+ - Receives the list of collected updates and converts to the final type
+ - There is only ONE finalizer per stream (set at construction)
+
+4. **Result Hooks** (`result_hooks=[]` or `.with_result_hook()`)
+ - Called ONCE after the finalizer produces its result
+ - Transform the final response before returning
+ - Multiple result hooks are called in order, each receiving the previous result
+ - Can return None to keep the previous value unchanged
+
+=== Two Consumption Patterns ===
+
+**Pattern 1: Async Iteration**
+```python
+async for update in response_stream:
+ print(update.text) # Process each update
+# Stream is now consumed; updates are stored internally
+```
+- Transform hooks are called for each yielded item
+- Cleanup hooks are called after the last item
+- The stream collects all updates internally for later finalization
+- Does not run the finalizer automatically
+
+**Pattern 2: Direct Finalization**
+```python
+final = await response_stream.get_final_response()
+```
+- If the stream hasn't been iterated, it auto-iterates (consuming all updates)
+- The finalizer converts collected updates to a final response
+- Result hooks transform the response
+- You get the complete response without ever seeing individual updates
+
+** Pattern 3: Combined Usage **
+
+When you first iterate the stream and then call `get_final_response()`, the following occurs:
+- Iteration yields updates with transform hooks applied
+- Cleanup hooks run after iteration completes
+- Calling `get_final_response()` uses the already collected updates to produce the final response
+- Note that it does not re-iterate the stream since it's already been consumed
+
+```python
+async for update in response_stream:
+ print(update.text) # See each update
+final = await response_stream.get_final_response() # Get the aggregated result
+```
+
+=== Chaining with .map() and .with_finalizer() ===
+
+When building a ChatAgent on top of a ChatClient, we face a challenge:
+- The ChatClient returns a ResponseStream[ChatResponseUpdate, ChatResponse]
+- The ChatAgent needs to return a ResponseStream[AgentResponseUpdate, AgentResponse]
+- We can't iterate the ChatClient's stream twice!
+
+The `.map()` and `.with_finalizer()` methods solve this by creating new ResponseStreams that:
+- Delegate iteration to the inner stream (only consuming it once)
+- Maintain their OWN separate transform hooks, result hooks, and cleanup hooks
+- Allow type-safe transformation of updates and final responses
+
+**`.map(transform)`**: Creates a new stream that transforms each update.
+- Returns a new ResponseStream with the transformed update type
+- Falls back to the inner stream's finalizer if no new finalizer is set
+
+**`.with_finalizer(finalizer)`**: Creates a new stream with a different finalizer.
+- Returns a new ResponseStream with the new final type
+- The inner stream's finalizer and result_hooks ARE still called (see below)
+
+**IMPORTANT**: When chaining these methods via `get_final_response()`:
+1. The inner stream's finalizer runs first (on the original updates)
+2. The inner stream's result_hooks run (on the inner final result)
+3. The outer stream's finalizer runs (on the transformed updates)
+4. The outer stream's result_hooks run (on the outer final result)
+
+This ensures that post-processing hooks registered on the inner stream (e.g., context
+provider notifications, telemetry, thread updates) are still executed even when the
+stream is wrapped/mapped.
+
+```python
+# ChatAgent does something like this internally:
+chat_stream = chat_client.get_response(messages, stream=True)
+agent_stream = (
+ chat_stream
+ .map(_to_agent_update, _to_agent_response)
+ .with_result_hook(_notify_thread) # Outer hook runs AFTER inner hooks
+)
+```
+
+This ensures:
+- The underlying ChatClient stream is only consumed once
+- The agent can add its own transform hooks, result hooks, and cleanup logic
+- Each layer (ChatClient, ChatAgent, middleware) can add independent behavior
+- Inner stream post-processing (like context provider notification) still runs
+- Types flow naturally through the chain
+"""
+
+
+async def main() -> None:
+ """Demonstrate the various ResponseStream patterns and capabilities."""
+
+ # =========================================================================
+ # Example 1: Basic ResponseStream with iteration
+ # =========================================================================
+ print("=== Example 1: Basic Iteration ===\n")
+
+ async def generate_updates() -> AsyncIterable[ChatResponseUpdate]:
+ """Simulate a streaming response from an AI model."""
+ words = ["Hello", " ", "from", " ", "the", " ", "streaming", " ", "response", "!"]
+ for word in words:
+ await asyncio.sleep(0.05) # Simulate network delay
+ yield ChatResponseUpdate(contents=[Content.from_text(word)], role=Role.ASSISTANT)
+
+ def combine_updates(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
+ """Finalizer that combines all updates into a single response."""
+ return ChatResponse.from_chat_response_updates(updates)
+
+ stream = ResponseStream(generate_updates(), finalizer=combine_updates)
+
+ print("Iterating through updates:")
+ async for update in stream:
+ print(f" Update: '{update.text}'")
+
+ # After iteration, we can still get the final response
+ final = await stream.get_final_response()
+ print(f"\nFinal response: '{final.text}'")
+
+ # =========================================================================
+ # Example 2: Using get_final_response() without iteration
+ # =========================================================================
+ print("\n=== Example 2: Direct Finalization (No Iteration) ===\n")
+
+ # Create a fresh stream (streams can only be consumed once)
+ stream2 = ResponseStream(generate_updates(), finalizer=combine_updates)
+
+ # Skip iteration entirely - get_final_response() auto-consumes the stream
+ final2 = await stream2.get_final_response()
+ print(f"Got final response directly: '{final2.text}'")
+ print(f"Number of updates collected internally: {len(stream2.updates)}")
+
+ # =========================================================================
+ # Example 3: Transform hooks - transform updates during iteration
+ # =========================================================================
+ print("\n=== Example 3: Transform Hooks ===\n")
+
+ update_count = {"value": 0}
+
+ def counting_hook(update: ChatResponseUpdate) -> ChatResponseUpdate:
+ """Hook that counts and annotates each update."""
+ update_count["value"] += 1
+ # Return the update (or a modified version)
+ return update
+
+ def uppercase_hook(update: ChatResponseUpdate) -> ChatResponseUpdate:
+ """Hook that converts text to uppercase."""
+ if update.text:
+ return ChatResponseUpdate(
+ contents=[Content.from_text(update.text.upper())], role=update.role, response_id=update.response_id
+ )
+ return update
+
+ # Pass transform_hooks directly to constructor
+ stream3 = ResponseStream(
+ generate_updates(),
+ finalizer=combine_updates,
+ transform_hooks=[counting_hook, uppercase_hook], # First counts, then uppercases
+ )
+
+ print("Iterating with hooks applied:")
+ async for update in stream3:
+ print(f" Received: '{update.text}'") # Will be uppercase
+
+ print(f"\nTotal updates processed: {update_count['value']}")
+
+ # =========================================================================
+ # Example 4: Cleanup hooks - cleanup after stream consumption
+ # =========================================================================
+ print("\n=== Example 4: Cleanup Hooks ===\n")
+
+ cleanup_performed = {"value": False}
+
+ async def cleanup_hook() -> None:
+ """Cleanup hook for releasing resources after stream consumption."""
+ print(" [Cleanup] Cleaning up resources...")
+ cleanup_performed["value"] = True
+
+ # Pass cleanup_hooks directly to constructor
+ stream4 = ResponseStream(
+ generate_updates(),
+ finalizer=combine_updates,
+ cleanup_hooks=[cleanup_hook],
+ )
+
+ print("Starting iteration (cleanup happens after):")
+ async for update in stream4:
+ pass # Just consume the stream
+ print(f"Cleanup was performed: {cleanup_performed['value']}")
+
+ # =========================================================================
+ # Example 5: Result hooks - transform the final response
+ # =========================================================================
+ print("\n=== Example 5: Result Hooks ===\n")
+
+ def add_metadata_hook(response: ChatResponse) -> ChatResponse:
+ """Result hook that adds metadata to the response."""
+ response.additional_properties["processed"] = True
+ response.additional_properties["word_count"] = len((response.text or "").split())
+ return response
+
+ def wrap_in_quotes_hook(response: ChatResponse) -> ChatResponse:
+ """Result hook that wraps the response text in quotes."""
+ if response.text:
+ return ChatResponse(
+ messages=f'"{response.text}"',
+ role=Role.ASSISTANT,
+ additional_properties=response.additional_properties,
+ )
+ return response
+
+ # Finalizer converts updates to response, then result hooks transform it
+ stream5 = ResponseStream(
+ generate_updates(),
+ finalizer=combine_updates,
+ result_hooks=[add_metadata_hook, wrap_in_quotes_hook], # First adds metadata, then wraps in quotes
+ )
+
+ final5 = await stream5.get_final_response()
+ print(f"Final text: {final5.text}")
+ print(f"Metadata: {final5.additional_properties}")
+
+ # =========================================================================
+ # Example 6: The wrap() API - layering without double-consumption
+ # =========================================================================
+ print("\n=== Example 6: wrap() API for Layering ===\n")
+
+ # Simulate what ChatClient returns
+ inner_stream = ResponseStream(generate_updates(), finalizer=combine_updates)
+
+ # Simulate what ChatAgent does: wrap the inner stream
+ def to_agent_format(update: ChatResponseUpdate) -> ChatResponseUpdate:
+ """Map ChatResponseUpdate to agent format (simulated transformation)."""
+ # In real code, this would convert to AgentResponseUpdate
+ return ChatResponseUpdate(
+ contents=[Content.from_text(f"[AGENT] {update.text}")], role=update.role, response_id=update.response_id
+ )
+
+ def to_agent_response(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
+ """Finalizer that converts updates to agent response (simulated)."""
+ # In real code, this would create an AgentResponse
+ text = "".join(u.text or "" for u in updates)
+ return ChatResponse(
+ text=f"[AGENT FINAL] {text}",
+ role=Role.ASSISTANT,
+ additional_properties={"layer": "agent"},
+ )
+
+ # .map() creates a new stream that:
+ # 1. Delegates iteration to inner_stream (only consuming it once)
+ # 2. Transforms each update via the transform function
+ # 3. Uses the provided finalizer (required since update type may change)
+ outer_stream = inner_stream.map(to_agent_format, to_agent_response)
+
+ print("Iterating the mapped stream:")
+ async for update in outer_stream:
+ print(f" {update.text}")
+
+ final_outer = await outer_stream.get_final_response()
+ print(f"\nMapped final: {final_outer.text}")
+ print(f"Mapped metadata: {final_outer.additional_properties}")
+
+ # Important: the inner stream was only consumed once!
+ print(f"Inner stream consumed: {inner_stream._consumed}")
+
+ # =========================================================================
+ # Example 7: Combining all patterns
+ # =========================================================================
+ print("\n=== Example 7: Full Integration ===\n")
+
+ stats = {"updates": 0, "characters": 0}
+
+ def track_stats(update: ChatResponseUpdate) -> ChatResponseUpdate:
+ """Track statistics as updates flow through."""
+ stats["updates"] += 1
+ stats["characters"] += len(update.text or "")
+ return update
+
+ def log_cleanup() -> None:
+ """Log when stream consumption completes."""
+ print(f" [Cleanup] Stream complete: {stats['updates']} updates, {stats['characters']} chars")
+
+ def add_stats_to_response(response: ChatResponse) -> ChatResponse:
+ """Result hook to include the statistics in the final response."""
+ response.additional_properties["stats"] = stats.copy()
+ return response
+
+ # All hooks can be passed via constructor
+ full_stream = ResponseStream(
+ generate_updates(),
+ finalizer=combine_updates,
+ transform_hooks=[track_stats],
+ result_hooks=[add_stats_to_response],
+ cleanup_hooks=[log_cleanup],
+ )
+
+ print("Processing with all hooks active:")
+ async for update in full_stream:
+ print(f" -> '{update.text}'")
+
+ final_full = await full_stream.get_final_response()
+ print(f"\nFinal: '{final_full.text}'")
+ print(f"Stats: {final_full.additional_properties['stats']}")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/python/samples/concepts/tools/README.md b/python/samples/concepts/tools/README.md
new file mode 100644
index 0000000000..3a270b25aa
--- /dev/null
+++ b/python/samples/concepts/tools/README.md
@@ -0,0 +1,499 @@
+# Tools and Middleware: Request Flow Architecture
+
+This document describes the complete request flow when using an Agent with middleware and tools, from the initial `Agent.run()` call through middleware layers, function invocation, and back to the caller.
+
+## Overview
+
+The Agent Framework uses a layered architecture with three distinct middleware/processing layers:
+
+1. **Agent Middleware Layer** - Wraps the entire agent execution
+2. **Chat Middleware Layer** - Wraps calls to the chat client
+3. **Function Middleware Layer** - Wraps individual tool/function invocations
+
+Each layer provides interception points where you can modify inputs, inspect outputs, or alter behavior.
+
+## Flow Diagram
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant Agent as Agent.run()
+ participant AML as AgentMiddlewareLayer
+ participant AMP as AgentMiddlewarePipeline
+ participant RawAgent as RawChatAgent.run()
+ participant CML as ChatMiddlewareLayer
+ participant CMP as ChatMiddlewarePipeline
+ participant FIL as FunctionInvocationLayer
+ participant Client as BaseChatClient._inner_get_response()
+ participant LLM as LLM Service
+ participant FMP as FunctionMiddlewarePipeline
+ participant Tool as FunctionTool.invoke()
+
+ User->>Agent: run(messages, thread, options, middleware)
+
+ Note over Agent,AML: Agent Middleware Layer
+ Agent->>AML: run() with middleware param
+ AML->>AML: categorize_middleware() → split by type
+ AML->>AMP: execute(AgentRunContext)
+
+ loop Agent Middleware Chain
+ AMP->>AMP: middleware[i].process(context, next)
+ Note right of AMP: Can modify: messages, options, thread
+ end
+
+ AMP->>RawAgent: run() via final_handler
+
+ alt Non-Streaming (stream=False)
+ RawAgent->>RawAgent: _prepare_run_context() [async]
+ Note right of RawAgent: Builds: thread_messages, chat_options, tools
+ RawAgent->>CML: chat_client.get_response(stream=False)
+ else Streaming (stream=True)
+ RawAgent->>RawAgent: ResponseStream.from_awaitable()
+ Note right of RawAgent: Defers async prep to stream consumption
+ RawAgent-->>User: Returns ResponseStream immediately
+ Note over RawAgent,CML: Async work happens on iteration
+ RawAgent->>RawAgent: _prepare_run_context() [deferred]
+ RawAgent->>CML: chat_client.get_response(stream=True)
+ end
+
+ Note over CML,CMP: Chat Middleware Layer
+ CML->>CMP: execute(ChatContext)
+
+ loop Chat Middleware Chain
+ CMP->>CMP: middleware[i].process(context, next)
+ Note right of CMP: Can modify: messages, options
+ end
+
+ CMP->>FIL: get_response() via final_handler
+
+ Note over FIL,Tool: Function Invocation Loop
+ loop Max Iterations (default: 40)
+ FIL->>Client: _inner_get_response(messages, options)
+ Client->>LLM: API Call
+ LLM-->>Client: Response (may include tool_calls)
+ Client-->>FIL: ChatResponse
+
+ alt Response has function_calls
+ FIL->>FIL: _extract_function_calls()
+ FIL->>FIL: _try_execute_function_calls()
+
+ Note over FIL,Tool: Function Middleware Layer
+ loop For each function_call
+ FIL->>FMP: execute(FunctionInvocationContext)
+ loop Function Middleware Chain
+ FMP->>FMP: middleware[i].process(context, next)
+ Note right of FMP: Can modify: arguments
+ end
+ FMP->>Tool: invoke(arguments)
+ Tool-->>FMP: result
+ FMP-->>FIL: Content.from_function_result()
+ end
+
+ FIL->>FIL: Append tool results to messages
+
+ alt tool_choice == "required"
+ Note right of FIL: Return immediately with function call + result
+ FIL-->>CMP: ChatResponse
+ else tool_choice == "auto" or other
+ Note right of FIL: Continue loop for text response
+ end
+ else No function_calls
+ FIL-->>CMP: ChatResponse
+ end
+ end
+
+ CMP-->>CML: ChatResponse
+ Note right of CMP: Can observe/modify result
+
+ CML-->>RawAgent: ChatResponse / ResponseStream
+
+ alt Non-Streaming
+ RawAgent->>RawAgent: _finalize_response_and_update_thread()
+ else Streaming
+ Note right of RawAgent: .map() transforms updates
+ Note right of RawAgent: .with_result_hook() runs post-processing
+ end
+
+ RawAgent-->>AMP: AgentResponse / ResponseStream
+ Note right of AMP: Can observe/modify result
+ AMP-->>AML: AgentResponse
+ AML-->>Agent: AgentResponse
+ Agent-->>User: AgentResponse / ResponseStream
+```
+
+## Layer Details
+
+### 1. Agent Middleware Layer (`AgentMiddlewareLayer`)
+
+**Entry Point:** `Agent.run(messages, thread, options, middleware)`
+
+**Context Object:** `AgentRunContext`
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `agent` | `AgentProtocol` | The agent being invoked |
+| `messages` | `list[ChatMessage]` | Input messages (mutable) |
+| `thread` | `AgentThread \| None` | Conversation thread |
+| `options` | `Mapping[str, Any]` | Chat options dict |
+| `stream` | `bool` | Whether streaming is enabled |
+| `metadata` | `dict` | Shared data between middleware |
+| `result` | `AgentResponse \| None` | Set after `next()` is called |
+| `kwargs` | `Mapping[str, Any]` | Additional run arguments |
+
+**Key Operations:**
+1. `categorize_middleware()` separates middleware by type (agent, chat, function)
+2. Chat and function middleware are forwarded to `chat_client`
+3. `AgentMiddlewarePipeline.execute()` runs the agent middleware chain
+4. Final handler calls `RawChatAgent.run()`
+
+**What Can Be Modified:**
+- `context.messages` - Add, remove, or modify input messages
+- `context.options` - Change model parameters, temperature, etc.
+- `context.thread` - Replace or modify the thread
+- `context.result` - Override the final response (after `next()`)
+
+### 2. Chat Middleware Layer (`ChatMiddlewareLayer`)
+
+**Entry Point:** `chat_client.get_response(messages, options)`
+
+**Context Object:** `ChatContext`
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `chat_client` | `ChatClientProtocol` | The chat client |
+| `messages` | `Sequence[ChatMessage]` | Messages to send |
+| `options` | `Mapping[str, Any]` | Chat options |
+| `stream` | `bool` | Whether streaming |
+| `metadata` | `dict` | Shared data between middleware |
+| `result` | `ChatResponse \| None` | Set after `next()` is called |
+| `kwargs` | `Mapping[str, Any]` | Additional arguments |
+
+**Key Operations:**
+1. `ChatMiddlewarePipeline.execute()` runs the chat middleware chain
+2. Final handler calls `FunctionInvocationLayer.get_response()`
+3. Stream hooks can be registered for streaming responses
+
+**What Can Be Modified:**
+- `context.messages` - Inject system prompts, filter content
+- `context.options` - Change model, temperature, tool_choice
+- `context.result` - Override the response (after `next()`)
+
+### 3. Function Invocation Layer (`FunctionInvocationLayer`)
+
+**Entry Point:** `FunctionInvocationLayer.get_response()`
+
+This layer manages the tool execution loop:
+
+1. **Calls** `BaseChatClient._inner_get_response()` to get LLM response
+2. **Extracts** function calls from the response
+3. **Executes** functions through the Function Middleware Pipeline
+4. **Appends** results to messages and loops back to step 1
+
+**Configuration:** `FunctionInvocationConfiguration`
+
+| Setting | Default | Description |
+|---------|---------|-------------|
+| `enabled` | `True` | Enable auto-invocation |
+| `max_iterations` | `40` | Maximum tool execution loops |
+| `max_consecutive_errors_per_request` | `3` | Error threshold before stopping |
+| `terminate_on_unknown_calls` | `False` | Raise error for unknown tools |
+| `additional_tools` | `[]` | Extra tools to register |
+| `include_detailed_errors` | `False` | Include exceptions in results |
+
+**`tool_choice` Behavior:**
+
+The `tool_choice` option controls how the model uses available tools:
+
+| Value | Behavior |
+|-------|----------|
+| `"auto"` | Model decides whether to call a tool or respond with text. After tool execution, the loop continues to get a text response. |
+| `"none"` | Model is prevented from calling tools, will only respond with text. |
+| `"required"` | Model **must** call a tool. After tool execution, returns immediately with the function call and result—**no additional model call** is made. |
+| `{"mode": "required", "required_function_name": "fn"}` | Model must call the specified function. Same return behavior as `"required"`. |
+
+**Why `tool_choice="required"` returns immediately:**
+
+When you set `tool_choice="required"`, your intent is to force one or more tool calls (not all models supports multiple, either by name or when using `required` without a name). The framework respects this by:
+1. Getting the model's function call(s)
+2. Executing the tool(s)
+3. Returning the response(s) with both the function call message(s) and the function result(s)
+
+This avoids an infinite loop (model forced to call tools → executes → model forced to call tools again) and gives you direct access to the tool result.
+
+```python
+# With tool_choice="required", response contains function call + result only
+response = await client.get_response(
+ "What's the weather?",
+ options={"tool_choice": "required", "tools": [get_weather]}
+)
+
+# response.messages contains:
+# [0] Assistant message with function_call content
+# [1] Tool message with function_result content
+# (No text response from model)
+
+# To get a text response after tool execution, use tool_choice="auto"
+response = await client.get_response(
+ "What's the weather?",
+ options={"tool_choice": "auto", "tools": [get_weather]}
+)
+# response.text contains the model's interpretation of the weather data
+```
+
+### 4. Function Middleware Layer (`FunctionMiddlewarePipeline`)
+
+**Entry Point:** Called per function invocation within `_auto_invoke_function()`
+
+**Context Object:** `FunctionInvocationContext`
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `function` | `FunctionTool` | The function being invoked |
+| `arguments` | `BaseModel` | Validated Pydantic arguments |
+| `metadata` | `dict` | Shared data between middleware |
+| `result` | `Any` | Set after `next()` is called |
+| `kwargs` | `Mapping[str, Any]` | Runtime kwargs |
+
+**What Can Be Modified:**
+- `context.arguments` - Modify validated arguments before execution
+- `context.result` - Override the function result (after `next()`)
+- Raise `MiddlewareTermination` to skip execution and terminate the function invocation loop
+
+**Special Behavior:** When `MiddlewareTermination` is raised in function middleware, it signals that the function invocation loop should exit **without making another LLM call**. This is useful when middleware determines that no further processing is needed (e.g., a termination condition is met).
+
+```python
+class TerminatingMiddleware(FunctionMiddleware):
+ async def process(self, context: FunctionInvocationContext, next):
+ if self.should_terminate(context):
+ context.result = "terminated by middleware"
+ raise MiddlewareTermination # Exit function invocation loop
+ await next(context)
+```
+
+## Arguments Added/Altered at Each Layer
+
+### Agent Layer → Chat Layer
+
+```python
+# RawChatAgent._prepare_run_context() builds:
+{
+ "thread": AgentThread, # Validated/created thread
+ "input_messages": [...], # Normalized input messages
+ "thread_messages": [...], # Messages from thread + context + input
+ "agent_name": "...", # Agent name for attribution
+ "chat_options": {
+ "model_id": "...",
+ "conversation_id": "...", # From thread.service_thread_id
+ "tools": [...], # Normalized tools + MCP tools
+ "temperature": ...,
+ "max_tokens": ...,
+ # ... other options
+ },
+ "filtered_kwargs": {...}, # kwargs minus 'chat_options'
+ "finalize_kwargs": {...}, # kwargs with 'thread' added
+}
+```
+
+### Chat Layer → Function Layer
+
+```python
+# Passed through to FunctionInvocationLayer:
+{
+ "messages": [...], # Prepared messages
+ "options": {...}, # Mutable copy of chat_options
+ "function_middleware": [...], # Function middleware from kwargs
+}
+```
+
+### Function Layer → Tool Invocation
+
+```python
+# FunctionInvocationContext receives:
+{
+ "function": FunctionTool, # The tool to invoke
+ "arguments": BaseModel, # Validated from function_call.arguments
+ "kwargs": {
+ # Runtime kwargs (filtered, no conversation_id)
+ },
+}
+```
+
+### Tool Result → Back Up
+
+```python
+# Content.from_function_result() creates:
+{
+ "type": "function_result",
+ "call_id": "...", # From function_call.call_id
+ "result": ..., # Serialized tool output
+ "exception": "..." | None, # Error message if failed
+}
+```
+
+## Middleware Control Flow
+
+There are three ways to exit a middleware's `process()` method:
+
+### 1. Return Normally (with or without calling `next`)
+
+Returns control to the upstream middleware, allowing its post-processing code to run.
+
+```python
+class CachingMiddleware(FunctionMiddleware):
+ async def process(self, context: FunctionInvocationContext, next):
+ # Option A: Return early WITHOUT calling next (skip downstream)
+ if cached := self.cache.get(context.function.name):
+ context.result = cached
+ return # Upstream post-processing still runs
+
+ # Option B: Call next, then return normally
+ await next(context)
+ self.cache[context.function.name] = context.result
+ return # Normal completion
+```
+
+### 2. Raise `MiddlewareTermination`
+
+Immediately exits the entire middleware chain. Upstream middleware's post-processing code is **skipped**.
+
+```python
+class BlockedFunctionMiddleware(FunctionMiddleware):
+ async def process(self, context: FunctionInvocationContext, next):
+ if context.function.name in self.blocked_functions:
+ context.result = "Function blocked by policy"
+ raise MiddlewareTermination("Blocked") # Skips ALL post-processing
+ await next(context)
+```
+
+### 3. Raise Any Other Exception
+
+Bubbles up to the caller. The middleware chain is aborted and the exception propagates.
+
+```python
+class ValidationMiddleware(FunctionMiddleware):
+ async def process(self, context: FunctionInvocationContext, next):
+ if not self.is_valid(context.arguments):
+ raise ValueError("Invalid arguments") # Bubbles up to user
+ await next(context)
+```
+
+## `return` vs `raise MiddlewareTermination`
+
+The key difference is what happens to **upstream middleware's post-processing**:
+
+```python
+class MiddlewareA(AgentMiddleware):
+ async def process(self, context, next):
+ print("A: before")
+ await next(context)
+ print("A: after") # Does this run?
+
+class MiddlewareB(AgentMiddleware):
+ async def process(self, context, next):
+ print("B: before")
+ context.result = "early result"
+ # Choose one:
+ return # Option 1
+ # raise MiddlewareTermination() # Option 2
+```
+
+With middleware registered as `[MiddlewareA, MiddlewareB]`:
+
+| Exit Method | Output |
+|-------------|--------|
+| `return` | `A: before` → `B: before` → `A: after` |
+| `raise MiddlewareTermination` | `A: before` → `B: before` (no `A: after`) |
+
+**Use `return`** when you want upstream middleware to still process the result (e.g., logging, metrics).
+
+**Use `raise MiddlewareTermination`** when you want to completely bypass all remaining processing (e.g., blocking a request, returning cached response without any modification).
+
+## Calling `next()` or Not
+
+The decision to call `next(context)` determines whether downstream middleware and the actual operation execute:
+
+### Without calling `next()` - Skip downstream
+
+```python
+async def process(self, context, next):
+ context.result = "replacement result"
+ return # Downstream middleware and actual execution are SKIPPED
+```
+
+- Downstream middleware: ❌ NOT executed
+- Actual operation (LLM call, function invocation): ❌ NOT executed
+- Upstream middleware post-processing: ✅ Still runs (unless `MiddlewareTermination` raised)
+- Result: Whatever you set in `context.result`
+
+### With calling `next()` - Full execution
+
+```python
+async def process(self, context, next):
+ # Pre-processing
+ await next(context) # Execute downstream + actual operation
+ # Post-processing (context.result now contains real result)
+ return
+```
+
+- Downstream middleware: ✅ Executed
+- Actual operation: ✅ Executed
+- Upstream middleware post-processing: ✅ Runs
+- Result: The actual result (possibly modified in post-processing)
+
+### Summary Table
+
+| Exit Method | Call `next()`? | Downstream Executes? | Actual Op Executes? | Upstream Post-Processing? |
+|-------------|----------------|---------------------|---------------------|--------------------------|
+| `return` (or implicit) | Yes | ✅ | ✅ | ✅ Yes |
+| `return` | No | ❌ | ❌ | ✅ Yes |
+| `raise MiddlewareTermination` | No | ❌ | ❌ | ❌ No |
+| `raise MiddlewareTermination` | Yes | ✅ | ✅ | ❌ No |
+| `raise OtherException` | Either | Depends | Depends | ❌ No (exception propagates) |
+
+> **Note:** The first row (`return` after calling `next()`) is the default behavior. Python functions implicitly return `None` at the end, so simply calling `await next(context)` without an explicit `return` statement achieves this pattern.
+
+## Streaming vs Non-Streaming
+
+The `run()` method handles streaming and non-streaming differently:
+
+### Non-Streaming (`stream=False`)
+
+Returns `Awaitable[AgentResponse]`:
+
+```python
+async def _run_non_streaming():
+ ctx = await self._prepare_run_context(...) # Async preparation
+ response = await self.chat_client.get_response(stream=False, ...)
+ await self._finalize_response_and_update_thread(...)
+ return AgentResponse(...)
+```
+
+### Streaming (`stream=True`)
+
+Returns `ResponseStream[AgentResponseUpdate, AgentResponse]` **synchronously**:
+
+```python
+# Async preparation is deferred using ResponseStream.from_awaitable()
+async def _get_stream():
+ ctx = await self._prepare_run_context(...) # Deferred until iteration
+ return self.chat_client.get_response(stream=True, ...)
+
+return (
+ ResponseStream.from_awaitable(_get_stream())
+ .map(
+ transform=map_chat_to_agent_update, # Transform each update
+ finalizer=self._finalize_response_updates, # Build final response
+ )
+ .with_result_hook(_post_hook) # Post-processing after finalization
+)
+```
+
+Key points:
+- `ResponseStream.from_awaitable()` wraps an async function, deferring execution until the stream is consumed
+- `.map()` transforms `ChatResponseUpdate` → `AgentResponseUpdate` and provides the finalizer
+- `.with_result_hook()` runs after finalization (e.g., notify thread of new messages)
+
+## See Also
+
+- [Middleware Samples](../../getting_started/middleware/) - Examples of custom middleware
+- [Function Tool Samples](../../getting_started/tools/) - Creating and using tools
diff --git a/python/samples/getting_started/chat_client/typed_options.py b/python/samples/concepts/typed_options.py
similarity index 100%
rename from python/samples/getting_started/chat_client/typed_options.py
rename to python/samples/concepts/typed_options.py
diff --git a/python/samples/demos/chatkit-integration/README.md b/python/samples/demos/chatkit-integration/README.md
index 688d24aebf..9636c4b190 100644
--- a/python/samples/demos/chatkit-integration/README.md
+++ b/python/samples/demos/chatkit-integration/README.md
@@ -118,7 +118,7 @@ agent_messages = await converter.to_agent_input(user_message_item)
# Running agent and streaming back to ChatKit
async for event in stream_agent_response(
- self.weather_agent.run_stream(agent_messages),
+ self.weather_agent.run(agent_messages, stream=True),
thread_id=thread.id,
):
yield event
diff --git a/python/samples/demos/chatkit-integration/app.py b/python/samples/demos/chatkit-integration/app.py
index 11b3140769..84ac060033 100644
--- a/python/samples/demos/chatkit-integration/app.py
+++ b/python/samples/demos/chatkit-integration/app.py
@@ -18,7 +18,7 @@
import uvicorn
# Agent Framework imports
-from agent_framework import AgentResponseUpdate, ChatAgent, ChatMessage, FunctionResultContent, tool
+from agent_framework import AgentResponseUpdate, ChatAgent, ChatMessage, FunctionResultContent, Role, tool
from agent_framework.azure import AzureOpenAIChatClient
# Agent Framework ChatKit integration
@@ -281,7 +281,7 @@ async def _update_thread_title(
title_prompt = [
ChatMessage(
- role="user",
+ role=Role.USER,
text=(
f"Generate a very short, concise title (max 40 characters) for a conversation "
f"that starts with:\n\n{conversation_context}\n\n"
@@ -366,7 +366,7 @@ async def respond(
logger.info(f"Running agent with {len(agent_messages)} message(s)")
# Run the Agent Framework agent with streaming
- agent_stream = self.weather_agent.run_stream(agent_messages)
+ agent_stream = self.weather_agent.run(agent_messages, stream=True)
# Create an intercepting stream that extracts function results while passing through updates
async def intercept_stream() -> AsyncIterator[AgentResponseUpdate]:
@@ -458,12 +458,12 @@ async def action(
weather_data: WeatherData | None = None
# Create an agent message asking about the weather
- agent_messages = [ChatMessage("user", [f"What's the weather in {city_label}?"])]
+ agent_messages = [ChatMessage(role=Role.USER, text=f"What's the weather in {city_label}?")]
logger.debug(f"Processing weather query: {agent_messages[0].text}")
# Run the Agent Framework agent with streaming
- agent_stream = self.weather_agent.run_stream(agent_messages)
+ agent_stream = self.weather_agent.run(agent_messages, stream=True)
# Create an intercepting stream that extracts function results while passing through updates
async def intercept_stream() -> AsyncIterator[AgentResponseUpdate]:
diff --git a/python/samples/demos/workflow_evaluation/create_workflow.py b/python/samples/demos/workflow_evaluation/create_workflow.py
index 665be0667e..e32916a864 100644
--- a/python/samples/demos/workflow_evaluation/create_workflow.py
+++ b/python/samples/demos/workflow_evaluation/create_workflow.py
@@ -189,7 +189,7 @@ async def _run_workflow_with_client(query: str, chat_client: AzureAIClient) -> d
workflow, agent_map = await _create_workflow(chat_client.project_client, chat_client.credential)
# Process workflow events
- events = workflow.run_stream(query)
+ events = workflow.run(query, stream=True)
workflow_output = await _process_workflow_events(events, conversation_ids, response_ids)
return {
diff --git a/python/samples/getting_started/agents/anthropic/anthropic_advanced.py b/python/samples/getting_started/agents/anthropic/anthropic_advanced.py
index 7ba38d12b7..4737903ca5 100644
--- a/python/samples/getting_started/agents/anthropic/anthropic_advanced.py
+++ b/python/samples/getting_started/agents/anthropic/anthropic_advanced.py
@@ -38,7 +38,7 @@ async def main() -> None:
query = "Can you compare Python decorators with C# attributes?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
for content in chunk.contents:
if isinstance(content, TextReasoningContent):
print(f"\033[32m{content.text}\033[0m", end="", flush=True)
diff --git a/python/samples/getting_started/agents/anthropic/anthropic_basic.py b/python/samples/getting_started/agents/anthropic/anthropic_basic.py
index 18a49d5e88..1600d725b6 100644
--- a/python/samples/getting_started/agents/anthropic/anthropic_basic.py
+++ b/python/samples/getting_started/agents/anthropic/anthropic_basic.py
@@ -55,7 +55,7 @@ async def streaming_example() -> None:
query = "What's the weather like in Portland and in Paris?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
diff --git a/python/samples/getting_started/agents/anthropic/anthropic_claude_basic.py b/python/samples/getting_started/agents/anthropic/anthropic_claude_basic.py
index f62cc60664..8bea9263de 100644
--- a/python/samples/getting_started/agents/anthropic/anthropic_claude_basic.py
+++ b/python/samples/getting_started/agents/anthropic/anthropic_claude_basic.py
@@ -59,7 +59,7 @@ async def streaming_example() -> None:
query = "What's the weather in Paris?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
diff --git a/python/samples/getting_started/agents/anthropic/anthropic_foundry.py b/python/samples/getting_started/agents/anthropic/anthropic_foundry.py
index 728e4915c3..ac7c9ac95d 100644
--- a/python/samples/getting_started/agents/anthropic/anthropic_foundry.py
+++ b/python/samples/getting_started/agents/anthropic/anthropic_foundry.py
@@ -49,7 +49,7 @@ async def main() -> None:
query = "Can you compare Python decorators with C# attributes?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
for content in chunk.contents:
if isinstance(content, TextReasoningContent):
print(f"\033[32m{content.text}\033[0m", end="", flush=True)
diff --git a/python/samples/getting_started/agents/anthropic/anthropic_skills.py b/python/samples/getting_started/agents/anthropic/anthropic_skills.py
index 009f485761..fa420269c0 100644
--- a/python/samples/getting_started/agents/anthropic/anthropic_skills.py
+++ b/python/samples/getting_started/agents/anthropic/anthropic_skills.py
@@ -53,7 +53,7 @@ async def main() -> None:
print(f"User: {query}")
print("Agent: ", end="", flush=True)
files: list[HostedFileContent] = []
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
for content in chunk.contents:
match content.type:
case "text":
diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_basic.py b/python/samples/getting_started/agents/azure_ai/azure_ai_basic.py
index 77465c3c52..d9a80a3732 100644
--- a/python/samples/getting_started/agents/azure_ai/azure_ai_basic.py
+++ b/python/samples/getting_started/agents/azure_ai/azure_ai_basic.py
@@ -68,7 +68,7 @@ async def streaming_example() -> None:
query = "What's the weather like in Tokyo?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_agent_as_tool.py b/python/samples/getting_started/agents/azure_ai/azure_ai_with_agent_as_tool.py
index 041f632d2f..b336e02d9d 100644
--- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_agent_as_tool.py
+++ b/python/samples/getting_started/agents/azure_ai/azure_ai_with_agent_as_tool.py
@@ -22,7 +22,7 @@ async def logging_middleware(
context: FunctionInvocationContext,
next: Callable[[FunctionInvocationContext], Awaitable[None]],
) -> None:
- """Middleware that logs tool invocations to show the delegation flow."""
+ """MiddlewareTypes that logs tool invocations to show the delegation flow."""
print(f"[Calling tool: {context.function.name}]")
print(f"[Request: {context.arguments}]")
diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter_file_download.py b/python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter_file_download.py
index 72e290e1b4..7e2b13635f 100644
--- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter_file_download.py
+++ b/python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter_file_download.py
@@ -11,7 +11,7 @@
Content,
HostedCodeInterpreterTool,
HostedFileContent,
- tool,
+ TextContent,
)
from agent_framework.azure import AzureAIProjectAgentProvider
from azure.identity.aio import AzureCliCredential
@@ -178,7 +178,7 @@ async def streaming_example() -> None:
file_contents_found: list[HostedFileContent] = []
text_chunks: list[str] = []
- async for update in agent.run_stream(QUERY):
+ async for update in agent.run(QUERY, stream=True):
if isinstance(update, AgentResponseUpdate):
for content in update.contents:
if content.type == "text":
diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter_file_generation.py b/python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter_file_generation.py
index 3e2b520ede..b0c83dc206 100644
--- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter_file_generation.py
+++ b/python/samples/getting_started/agents/azure_ai/azure_ai_with_code_interpreter_file_generation.py
@@ -78,7 +78,7 @@ async def streaming_example() -> None:
text_chunks: list[str] = []
file_ids_found: list[str] = []
- async for update in agent.run_stream(QUERY):
+ async for update in agent.run(QUERY, stream=True):
if isinstance(update, AgentResponseUpdate):
for content in update.contents:
if content.type == "text":
diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_reasoning.py b/python/samples/getting_started/agents/azure_ai/azure_ai_with_reasoning.py
index 0cb6955620..06da57ea60 100644
--- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_reasoning.py
+++ b/python/samples/getting_started/agents/azure_ai/azure_ai_with_reasoning.py
@@ -68,7 +68,7 @@ async def streaming_example() -> None:
shown_reasoning_label = False
shown_text_label = False
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
for content in chunk.contents:
if content.type == "text_reasoning":
if not shown_reasoning_label:
diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_basic.py b/python/samples/getting_started/agents/azure_ai_agent/azure_ai_basic.py
index e06232cf56..34bd782a9b 100644
--- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_basic.py
+++ b/python/samples/getting_started/agents/azure_ai_agent/azure_ai_basic.py
@@ -66,7 +66,7 @@ async def streaming_example() -> None:
query = "What's the weather like in Portland?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_azure_ai_search.py b/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_azure_ai_search.py
index 52da0c450c..20ccfe8de6 100644
--- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_azure_ai_search.py
+++ b/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_azure_ai_search.py
@@ -87,7 +87,7 @@ async def main() -> None:
print("Agent: ", end="", flush=True)
# Stream the response and collect citations
citations: list[Annotation] = []
- async for chunk in agent.run_stream(user_input):
+ async for chunk in agent.run(user_input, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
# Collect citations from Azure AI Search responses
diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_bing_grounding_citations.py b/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_bing_grounding_citations.py
index b1483b141b..fd1f321741 100644
--- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_bing_grounding_citations.py
+++ b/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_bing_grounding_citations.py
@@ -58,7 +58,7 @@ async def main() -> None:
# Stream the response and collect citations
citations: list[Annotation] = []
- async for chunk in agent.run_stream(user_input):
+ async for chunk in agent.run(user_input, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
diff --git a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_code_interpreter_file_generation.py b/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_code_interpreter_file_generation.py
index 665c707adc..385ca4dc92 100644
--- a/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_code_interpreter_file_generation.py
+++ b/python/samples/getting_started/agents/azure_ai_agent/azure_ai_with_code_interpreter_file_generation.py
@@ -4,7 +4,6 @@
import os
from agent_framework import (
- AgentResponseUpdate,
HostedCodeInterpreterTool,
HostedFileContent,
)
@@ -60,10 +59,7 @@ async def main() -> None:
# Collect file_ids from the response
file_ids: list[str] = []
- async for chunk in agent.run_stream(query):
- if not isinstance(chunk, AgentResponseUpdate):
- continue
-
+ async for chunk in agent.run(query, stream=True):
for content in chunk.contents:
if content.type == "text":
print(content.text, end="", flush=True)
diff --git a/python/samples/getting_started/agents/azure_openai/azure_assistants_basic.py b/python/samples/getting_started/agents/azure_openai/azure_assistants_basic.py
index 243ba55bf3..2bc74ef83c 100644
--- a/python/samples/getting_started/agents/azure_openai/azure_assistants_basic.py
+++ b/python/samples/getting_started/agents/azure_openai/azure_assistants_basic.py
@@ -58,7 +58,7 @@ async def streaming_example() -> None:
query = "What's the weather like in Portland?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
diff --git a/python/samples/getting_started/agents/azure_openai/azure_assistants_with_code_interpreter.py b/python/samples/getting_started/agents/azure_openai/azure_assistants_with_code_interpreter.py
index b37af8f8de..3445bbcbc0 100644
--- a/python/samples/getting_started/agents/azure_openai/azure_assistants_with_code_interpreter.py
+++ b/python/samples/getting_started/agents/azure_openai/azure_assistants_with_code_interpreter.py
@@ -55,7 +55,7 @@ async def main() -> None:
print(f"User: {query}")
print("Agent: ", end="", flush=True)
generated_code = ""
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
code_interpreter_chunk = get_code_interpreter_chunk(chunk)
diff --git a/python/samples/getting_started/agents/azure_openai/azure_chat_client_basic.py b/python/samples/getting_started/agents/azure_openai/azure_chat_client_basic.py
index feb2ab5f89..e1e9fab2f5 100644
--- a/python/samples/getting_started/agents/azure_openai/azure_chat_client_basic.py
+++ b/python/samples/getting_started/agents/azure_openai/azure_chat_client_basic.py
@@ -60,7 +60,7 @@ async def streaming_example() -> None:
query = "What's the weather like in Portland?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
diff --git a/python/samples/getting_started/agents/azure_openai/azure_responses_client_basic.py b/python/samples/getting_started/agents/azure_openai/azure_responses_client_basic.py
index af79b0465c..de20e03c4a 100644
--- a/python/samples/getting_started/agents/azure_openai/azure_responses_client_basic.py
+++ b/python/samples/getting_started/agents/azure_openai/azure_responses_client_basic.py
@@ -58,7 +58,7 @@ async def streaming_example() -> None:
query = "What's the weather like in Portland?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
diff --git a/python/samples/getting_started/agents/azure_openai/azure_responses_client_with_hosted_mcp.py b/python/samples/getting_started/agents/azure_openai/azure_responses_client_with_hosted_mcp.py
index 7d346c8fc8..ec96a10dcd 100644
--- a/python/samples/getting_started/agents/azure_openai/azure_responses_client_with_hosted_mcp.py
+++ b/python/samples/getting_started/agents/azure_openai/azure_responses_client_with_hosted_mcp.py
@@ -30,10 +30,10 @@ async def handle_approvals_without_thread(query: str, agent: "AgentProtocol"):
f"User Input Request for function from {agent.name}: {user_input_needed.function_call.name}"
f" with arguments: {user_input_needed.function_call.arguments}"
)
- new_inputs.append(ChatMessage("assistant", [user_input_needed]))
+ new_inputs.append(ChatMessage(role="assistant", contents=[user_input_needed]))
user_approval = input("Approve function call? (y/n): ")
new_inputs.append(
- ChatMessage("user", [user_input_needed.to_function_approval_response(user_approval.lower() == "y")])
+ ChatMessage(role="user", contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")])
)
result = await agent.run(new_inputs)
@@ -71,8 +71,8 @@ async def handle_approvals_with_thread_streaming(query: str, agent: "AgentProtoc
new_input_added = True
while new_input_added:
new_input_added = False
- new_input.append(ChatMessage("user", [query]))
- async for update in agent.run_stream(new_input, thread=thread, store=True):
+ new_input.append(ChatMessage(role="user", text=query))
+ async for update in agent.run(new_input, thread=thread, options={"store": True}, stream=True):
if update.user_input_requests:
for user_input_needed in update.user_input_requests:
print(
diff --git a/python/samples/getting_started/agents/copilotstudio/copilotstudio_basic.py b/python/samples/getting_started/agents/copilotstudio/copilotstudio_basic.py
index e3b571a664..760ed4d127 100644
--- a/python/samples/getting_started/agents/copilotstudio/copilotstudio_basic.py
+++ b/python/samples/getting_started/agents/copilotstudio/copilotstudio_basic.py
@@ -39,7 +39,7 @@ async def streaming_example() -> None:
query = "What is the capital of Spain?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
diff --git a/python/samples/getting_started/agents/custom/README.md b/python/samples/getting_started/agents/custom/README.md
index 62e426b7af..eba87c4350 100644
--- a/python/samples/getting_started/agents/custom/README.md
+++ b/python/samples/getting_started/agents/custom/README.md
@@ -7,20 +7,63 @@ This folder contains examples demonstrating how to implement custom agents and c
| File | Description |
|------|-------------|
| [`custom_agent.py`](custom_agent.py) | Shows how to create custom agents by extending the `BaseAgent` class. Demonstrates the `EchoAgent` implementation with both streaming and non-streaming responses, proper thread management, and message history handling. |
-| [`custom_chat_client.py`](custom_chat_client.py) | Demonstrates how to create custom chat clients by extending the `BaseChatClient` class. Shows the `EchoingChatClient` implementation and how to integrate it with `ChatAgent` using the `create_agent()` method. |
+| [`custom_chat_client.py`](../../chat_client/custom_chat_client.py) | Demonstrates how to create custom chat clients by extending the `BaseChatClient` class. Shows a `EchoingChatClient` implementation and how to integrate it with `ChatAgent` using the `as_agent()` method. |
## Key Takeaways
### Custom Agents
- Custom agents give you complete control over the agent's behavior
-- You must implement both `run()` (for complete responses) and `run_stream()` (for streaming responses)
+- You must implement both `run()` for both the `stream=True` and `stream=False` cases
- Use `self._normalize_messages()` to handle different input message formats
- Use `self._notify_thread_of_new_messages()` to properly manage conversation history
### Custom Chat Clients
- Custom chat clients allow you to integrate any backend service or create new LLM providers
-- You must implement both `_inner_get_response()` and `_inner_get_streaming_response()`
+- You must implement `_inner_get_response()` with a stream parameter to handle both streaming and non-streaming responses
- Custom chat clients can be used with `ChatAgent` to leverage all agent framework features
-- Use the `create_agent()` method to easily create agents from your custom chat clients
+- Use the `as_agent()` method to easily create agents from your custom chat clients
-Both approaches allow you to extend the framework for your specific use cases while maintaining compatibility with the broader Agent Framework ecosystem.
\ No newline at end of file
+Both approaches allow you to extend the framework for your specific use cases while maintaining compatibility with the broader Agent Framework ecosystem.
+
+## Understanding Raw Client Classes
+
+The framework provides `Raw...Client` classes (e.g., `RawOpenAIChatClient`, `RawOpenAIResponsesClient`, `RawAzureAIClient`) that are intermediate implementations without middleware, telemetry, or function invocation support.
+
+### Warning: Raw Clients Should Not Normally Be Used Directly
+
+**The `Raw...Client` classes should not normally be used directly.** They do not include the middleware, telemetry, or function invocation support that you most likely need. If you do use them, you should carefully consider which additional layers to apply.
+
+### Layer Ordering
+
+There is a defined ordering for applying layers that you should follow:
+
+1. **ChatMiddlewareLayer** - Should be applied **first** because it also prepares function middleware
+2. **FunctionInvocationLayer** - Handles tool/function calling loop
+3. **ChatTelemetryLayer** - Must be **inside** the function calling loop for correct per-call telemetry
+4. **Raw...Client** - The base implementation (e.g., `RawOpenAIChatClient`)
+
+Example of correct layer composition:
+
+```python
+class MyCustomClient(
+ ChatMiddlewareLayer[TOptions],
+ FunctionInvocationLayer[TOptions],
+ ChatTelemetryLayer[TOptions],
+ RawOpenAIChatClient[TOptions], # or BaseChatClient for custom implementations
+ Generic[TOptions],
+):
+ """Custom client with all layers correctly applied."""
+ pass
+```
+
+### Use Fully-Featured Clients Instead
+
+For most use cases, use the fully-featured public client classes which already have all layers correctly composed:
+
+- `OpenAIChatClient` - OpenAI Chat completions with all layers
+- `OpenAIResponsesClient` - OpenAI Responses API with all layers
+- `AzureOpenAIChatClient` - Azure OpenAI Chat with all layers
+- `AzureOpenAIResponsesClient` - Azure OpenAI Responses with all layers
+- `AzureAIClient` - Azure AI Project with all layers
+
+These clients handle the layer composition correctly and provide the full feature set out of the box.
diff --git a/python/samples/getting_started/agents/custom/custom_agent.py b/python/samples/getting_started/agents/custom/custom_agent.py
index cc3c376964..c29424dcbf 100644
--- a/python/samples/getting_started/agents/custom/custom_agent.py
+++ b/python/samples/getting_started/agents/custom/custom_agent.py
@@ -11,6 +11,8 @@
BaseAgent,
ChatMessage,
Content,
+ Role,
+ TextContent,
)
"""
@@ -25,7 +27,7 @@ class EchoAgent(BaseAgent):
"""A simple custom agent that echoes user messages with a prefix.
This demonstrates how to create a fully custom agent by extending BaseAgent
- and implementing the required run() and run_stream() methods.
+ and implementing the required run() method with stream support.
"""
echo_prefix: str = "Echo: "
@@ -53,30 +55,45 @@ def __init__(
**kwargs,
)
- async def run(
+ def run(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
+ stream: bool = False,
thread: AgentThread | None = None,
**kwargs: Any,
- ) -> AgentResponse:
- """Execute the agent and return a complete response.
+ ) -> "AsyncIterable[AgentResponseUpdate] | asyncio.Future[AgentResponse]":
+ """Execute the agent and return a response.
Args:
messages: The message(s) to process.
+ stream: If True, return an async iterable of updates. If False, return an awaitable response.
thread: The conversation thread (optional).
**kwargs: Additional keyword arguments.
Returns:
- An AgentResponse containing the agent's reply.
+ When stream=False: An awaitable AgentResponse containing the agent's reply.
+ When stream=True: An async iterable of AgentResponseUpdate objects.
"""
+ if stream:
+ return self._run_stream(messages=messages, thread=thread, **kwargs)
+ return self._run(messages=messages, thread=thread, **kwargs)
+
+ async def _run(
+ self,
+ messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
+ *,
+ thread: AgentThread | None = None,
+ **kwargs: Any,
+ ) -> AgentResponse:
+ """Non-streaming implementation."""
# Normalize input messages to a list
normalized_messages = self._normalize_messages(messages)
if not normalized_messages:
response_message = ChatMessage(
- "assistant",
- [Content.from_text(text="Hello! I'm a custom echo agent. Send me a message and I'll echo it back.")],
+ role=Role.ASSISTANT,
+ contents=[Content.from_text(text="Hello! I'm a custom echo agent. Send me a message and I'll echo it back.")],
)
else:
# For simplicity, echo the last user message
@@ -86,7 +103,7 @@ async def run(
else:
echo_text = f"{self.echo_prefix}[Non-text message received]"
- response_message = ChatMessage("assistant", [Content.from_text(text=echo_text)])
+ response_message = ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text(text=echo_text)])
# Notify the thread of new messages if provided
if thread is not None:
@@ -94,23 +111,14 @@ async def run(
return AgentResponse(messages=[response_message])
- async def run_stream(
+ async def _run_stream(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]:
- """Execute the agent and yield streaming response updates.
-
- Args:
- messages: The message(s) to process.
- thread: The conversation thread (optional).
- **kwargs: Additional keyword arguments.
-
- Yields:
- AgentResponseUpdate objects containing chunks of the response.
- """
+ """Streaming implementation."""
# Normalize input messages to a list
normalized_messages = self._normalize_messages(messages)
@@ -132,7 +140,7 @@ async def run_stream(
yield AgentResponseUpdate(
contents=[Content.from_text(text=chunk_text)],
- role="assistant",
+ role=Role.ASSISTANT,
)
# Small delay to simulate streaming
@@ -140,7 +148,7 @@ async def run_stream(
# Notify the thread of the complete response if provided
if thread is not None:
- complete_response = ChatMessage("assistant", [Content.from_text(text=response_text)])
+ complete_response = ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text(text=response_text)])
await self._notify_thread_of_new_messages(thread, normalized_messages, complete_response)
@@ -167,7 +175,7 @@ async def main() -> None:
query2 = "This is a streaming test"
print(f"\nUser: {query2}")
print("Agent: ", end="", flush=True)
- async for chunk in echo_agent.run_stream(query2):
+ async for chunk in echo_agent.run(query2, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print()
diff --git a/python/samples/getting_started/agents/github_copilot/github_copilot_basic.py b/python/samples/getting_started/agents/github_copilot/github_copilot_basic.py
index d23591eb02..0e2fa722b6 100644
--- a/python/samples/getting_started/agents/github_copilot/github_copilot_basic.py
+++ b/python/samples/getting_started/agents/github_copilot/github_copilot_basic.py
@@ -61,7 +61,7 @@ async def streaming_example() -> None:
query = "What's the weather like in Tokyo?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
diff --git a/python/samples/getting_started/agents/ollama/ollama_agent_basic.py b/python/samples/getting_started/agents/ollama/ollama_agent_basic.py
index 80b17e3b39..6477e620f0 100644
--- a/python/samples/getting_started/agents/ollama/ollama_agent_basic.py
+++ b/python/samples/getting_started/agents/ollama/ollama_agent_basic.py
@@ -54,7 +54,7 @@ async def streaming_example() -> None:
query = "What time is it in San Francisco? Use a tool call"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
diff --git a/python/samples/getting_started/agents/ollama/ollama_agent_reasoning.py b/python/samples/getting_started/agents/ollama/ollama_agent_reasoning.py
index 3250926030..ee22f5775b 100644
--- a/python/samples/getting_started/agents/ollama/ollama_agent_reasoning.py
+++ b/python/samples/getting_started/agents/ollama/ollama_agent_reasoning.py
@@ -2,7 +2,6 @@
import asyncio
-from agent_framework import TextReasoningContent
from agent_framework.ollama import OllamaChatClient
"""
@@ -18,7 +17,7 @@
"""
-async def reasoning_example() -> None:
+async def main() -> None:
print("=== Response Reasoning Example ===")
agent = OllamaChatClient().as_agent(
@@ -30,16 +29,10 @@ async def reasoning_example() -> None:
print(f"User: {query}")
# Enable Reasoning on per request level
result = await agent.run(query)
- reasoning = "".join((c.text or "") for c in result.messages[-1].contents if isinstance(c, TextReasoningContent))
+ reasoning = "".join((c.text or "") for c in result.messages[-1].contents if c.type == "text_reasoning")
print(f"Reasoning: {reasoning}")
print(f"Answer: {result}\n")
-async def main() -> None:
- print("=== Basic Ollama Chat Client Agent Reasoning ===")
-
- await reasoning_example()
-
-
if __name__ == "__main__":
asyncio.run(main())
diff --git a/python/samples/getting_started/agents/ollama/ollama_chat_client.py b/python/samples/getting_started/agents/ollama/ollama_chat_client.py
index 67c71ff249..07dd5cc368 100644
--- a/python/samples/getting_started/agents/ollama/ollama_chat_client.py
+++ b/python/samples/getting_started/agents/ollama/ollama_chat_client.py
@@ -33,7 +33,7 @@ async def main() -> None:
print(f"User: {message}")
if stream:
print("Assistant: ", end="")
- async for chunk in client.get_streaming_response(message, tools=get_time):
+ async for chunk in client.get_response(message, tools=get_time, stream=True):
if str(chunk):
print(str(chunk), end="")
print("")
diff --git a/python/samples/getting_started/agents/ollama/ollama_with_openai_chat_client.py b/python/samples/getting_started/agents/ollama/ollama_with_openai_chat_client.py
index b555b7789f..da2468cb22 100644
--- a/python/samples/getting_started/agents/ollama/ollama_with_openai_chat_client.py
+++ b/python/samples/getting_started/agents/ollama/ollama_with_openai_chat_client.py
@@ -68,7 +68,7 @@ async def streaming_example() -> None:
query = "What's the weather like in Portland?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
diff --git a/python/samples/getting_started/agents/openai/openai_assistants_basic.py b/python/samples/getting_started/agents/openai/openai_assistants_basic.py
index eb267b4a88..2fa4f79094 100644
--- a/python/samples/getting_started/agents/openai/openai_assistants_basic.py
+++ b/python/samples/getting_started/agents/openai/openai_assistants_basic.py
@@ -72,7 +72,7 @@ async def streaming_example() -> None:
query = "What's the weather like in Portland?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
diff --git a/python/samples/getting_started/agents/openai/openai_assistants_with_code_interpreter.py b/python/samples/getting_started/agents/openai/openai_assistants_with_code_interpreter.py
index b4a25b8465..0599e796ea 100644
--- a/python/samples/getting_started/agents/openai/openai_assistants_with_code_interpreter.py
+++ b/python/samples/getting_started/agents/openai/openai_assistants_with_code_interpreter.py
@@ -60,7 +60,7 @@ async def main() -> None:
print(f"User: {query}")
print("Agent: ", end="", flush=True)
generated_code = ""
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
code_interpreter_chunk = get_code_interpreter_chunk(chunk)
diff --git a/python/samples/getting_started/agents/openai/openai_assistants_with_file_search.py b/python/samples/getting_started/agents/openai/openai_assistants_with_file_search.py
index 035b6e88f2..0046be1206 100644
--- a/python/samples/getting_started/agents/openai/openai_assistants_with_file_search.py
+++ b/python/samples/getting_started/agents/openai/openai_assistants_with_file_search.py
@@ -3,7 +3,7 @@
import asyncio
import os
-from agent_framework import HostedFileSearchTool, HostedVectorStoreContent
+from agent_framework import Content, HostedFileSearchTool
from agent_framework.openai import OpenAIAssistantProvider
from openai import AsyncOpenAI
@@ -15,7 +15,7 @@
"""
-async def create_vector_store(client: AsyncOpenAI) -> tuple[str, HostedVectorStoreContent]:
+async def create_vector_store(client: AsyncOpenAI) -> tuple[str, Content]:
"""Create a vector store with sample documents."""
file = await client.files.create(
file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."), purpose="user_data"
@@ -28,7 +28,7 @@ async def create_vector_store(client: AsyncOpenAI) -> tuple[str, HostedVectorSto
if result.last_error is not None:
raise Exception(f"Vector store file processing failed with status: {result.last_error.message}")
- return file.id, HostedVectorStoreContent(vector_store_id=vector_store.id)
+ return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id)
async def delete_vector_store(client: AsyncOpenAI, file_id: str, vector_store_id: str) -> None:
@@ -56,8 +56,10 @@ async def main() -> None:
print(f"User: {query}")
print("Agent: ", end="", flush=True)
- async for chunk in agent.run_stream(
- query, tool_resources={"file_search": {"vector_store_ids": [vector_store.vector_store_id]}}
+ async for chunk in agent.run(
+ query,
+ stream=True,
+ options={"tool_resources": {"file_search": {"vector_store_ids": [vector_store.vector_store_id]}}},
):
if chunk.text:
print(chunk.text, end="", flush=True)
diff --git a/python/samples/getting_started/agents/openai/openai_chat_client_basic.py b/python/samples/getting_started/agents/openai/openai_chat_client_basic.py
index 49cfb29447..b7137b2d43 100644
--- a/python/samples/getting_started/agents/openai/openai_chat_client_basic.py
+++ b/python/samples/getting_started/agents/openai/openai_chat_client_basic.py
@@ -54,7 +54,7 @@ async def streaming_example() -> None:
query = "What's the weather like in Portland?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
diff --git a/python/samples/getting_started/agents/openai/openai_chat_client_with_runtime_json_schema.py b/python/samples/getting_started/agents/openai/openai_chat_client_with_runtime_json_schema.py
index 945b2deff8..f1f39db38a 100644
--- a/python/samples/getting_started/agents/openai/openai_chat_client_with_runtime_json_schema.py
+++ b/python/samples/getting_started/agents/openai/openai_chat_client_with_runtime_json_schema.py
@@ -74,8 +74,9 @@ async def streaming_example() -> None:
print(f"User: {query}")
chunks: list[str] = []
- async for chunk in agent.run_stream(
+ async for chunk in agent.run(
query,
+ stream=True,
options={
"response_format": {
"type": "json_schema",
diff --git a/python/samples/getting_started/agents/openai/openai_chat_client_with_web_search.py b/python/samples/getting_started/agents/openai/openai_chat_client_with_web_search.py
index c317e163ad..eb1072f945 100644
--- a/python/samples/getting_started/agents/openai/openai_chat_client_with_web_search.py
+++ b/python/samples/getting_started/agents/openai/openai_chat_client_with_web_search.py
@@ -34,7 +34,7 @@ async def main() -> None:
if stream:
print("Assistant: ", end="")
- async for chunk in agent.run_stream(message):
+ async for chunk in agent.run(message, stream=True):
if chunk.text:
print(chunk.text, end="")
print("")
diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_basic.py b/python/samples/getting_started/agents/openai/openai_responses_client_basic.py
index 4e7fcbf07d..06ecb55473 100644
--- a/python/samples/getting_started/agents/openai/openai_responses_client_basic.py
+++ b/python/samples/getting_started/agents/openai/openai_responses_client_basic.py
@@ -1,10 +1,11 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
+from collections.abc import Awaitable, Callable
from random import randint
from typing import Annotated
-from agent_framework import ChatAgent, tool
+from agent_framework import ChatAgent, ChatContext, ChatMessage, ChatResponse, Role, chat_middleware, tool
from agent_framework.openai import OpenAIResponsesClient
from pydantic import Field
@@ -16,6 +17,47 @@
"""
+@chat_middleware
+async def security_and_override_middleware(
+ context: ChatContext,
+ next: Callable[[ChatContext], Awaitable[None]],
+) -> None:
+ """Function-based middleware that implements security filtering and response override."""
+ print("[SecurityMiddleware] Processing input...")
+
+ # Security check - block sensitive information
+ blocked_terms = ["password", "secret", "api_key", "token"]
+
+ for message in context.messages:
+ if message.text:
+ message_lower = message.text.lower()
+ for term in blocked_terms:
+ if term in message_lower:
+ print(f"[SecurityMiddleware] BLOCKED: Found '{term}' in message")
+
+ # Override the response instead of calling AI
+ context.result = ChatResponse(
+ messages=[
+ ChatMessage(
+ role=Role.ASSISTANT,
+ text="I cannot process requests containing sensitive information. "
+ "Please rephrase your question without including passwords, secrets, or other "
+ "sensitive data.",
+ )
+ ]
+ )
+
+ # Set terminate flag to stop execution
+ context.terminate = True
+ return
+
+ # Continue to next middleware or AI execution
+ await next(context)
+
+ print("[SecurityMiddleware] Response generated.")
+ print(type(context.result))
+
+
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py.
@tool(approval_mode="never_require")
def get_weather(
@@ -47,25 +89,29 @@ async def streaming_example() -> None:
print("=== Streaming Response Example ===")
agent = ChatAgent(
- chat_client=OpenAIResponsesClient(),
+ chat_client=OpenAIResponsesClient(
+ middleware=[security_and_override_middleware],
+ ),
instructions="You are a helpful weather agent.",
- tools=get_weather,
+ # tools=get_weather,
)
query = "What's the weather like in Portland?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
- async for chunk in agent.run_stream(query):
+ response = agent.run(query, stream=True)
+ async for chunk in response:
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
+ print(f"Final Result: {await response.get_final_response()}")
async def main() -> None:
print("=== Basic OpenAI Responses Client Agent Example ===")
- await non_streaming_example()
await streaming_example()
+ await non_streaming_example()
if __name__ == "__main__":
diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_image_generation.py b/python/samples/getting_started/agents/openai/openai_responses_client_image_generation.py
index 9d9fcbf546..635b99e85f 100644
--- a/python/samples/getting_started/agents/openai/openai_responses_client_image_generation.py
+++ b/python/samples/getting_started/agents/openai/openai_responses_client_image_generation.py
@@ -3,7 +3,7 @@
import asyncio
import base64
-from agent_framework import Content, HostedImageGenerationTool, ImageGenerationToolResultContent
+from agent_framework import HostedImageGenerationTool
from agent_framework.openai import OpenAIResponsesClient
"""
@@ -70,7 +70,7 @@ async def main() -> None:
# Show information about the generated image
for message in result.messages:
for content in message.contents:
- if isinstance(content, ImageGenerationToolResultContent) and content.outputs:
+ if content.type == "image_generation" and content.outputs:
for output in content.outputs:
if output.type in ("data", "uri") and output.uri:
show_image_info(output.uri)
diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_reasoning.py b/python/samples/getting_started/agents/openai/openai_responses_client_reasoning.py
index 06080db943..d920ba32c6 100644
--- a/python/samples/getting_started/agents/openai/openai_responses_client_reasoning.py
+++ b/python/samples/getting_started/agents/openai/openai_responses_client_reasoning.py
@@ -55,7 +55,7 @@ async def streaming_reasoning_example() -> None:
print(f"User: {query}")
print(f"{agent.name}: ", end="", flush=True)
usage = None
- async for chunk in agent.run_stream(query):
+ async for chunk in agent.run(query, stream=True):
if chunk.contents:
for content in chunk.contents:
if content.type == "text_reasoning":
diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_streaming_image_generation.py b/python/samples/getting_started/agents/openai/openai_responses_client_streaming_image_generation.py
index c5373b69f7..52e1e42eda 100644
--- a/python/samples/getting_started/agents/openai/openai_responses_client_streaming_image_generation.py
+++ b/python/samples/getting_started/agents/openai/openai_responses_client_streaming_image_generation.py
@@ -67,7 +67,7 @@ async def main():
await output_dir.mkdir(exist_ok=True)
print(" Streaming response:")
- async for update in agent.run_stream(query):
+ async for update in agent.run(query, stream=True):
for content in update.contents:
# Handle partial images
# The final partial image IS the complete, full-quality image. Each partial
diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_agent_as_tool.py b/python/samples/getting_started/agents/openai/openai_responses_client_with_agent_as_tool.py
index 13b472e2a3..d90202a9af 100644
--- a/python/samples/getting_started/agents/openai/openai_responses_client_with_agent_as_tool.py
+++ b/python/samples/getting_started/agents/openai/openai_responses_client_with_agent_as_tool.py
@@ -21,7 +21,7 @@ async def logging_middleware(
context: FunctionInvocationContext,
next: Callable[[FunctionInvocationContext], Awaitable[None]],
) -> None:
- """Middleware that logs tool invocations to show the delegation flow."""
+ """MiddlewareTypes that logs tool invocations to show the delegation flow."""
print(f"[Calling tool: {context.function.name}]")
print(f"[Request: {context.arguments}]")
diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_code_interpreter.py b/python/samples/getting_started/agents/openai/openai_responses_client_with_code_interpreter.py
index 5a73752bd9..29f8fa358a 100644
--- a/python/samples/getting_started/agents/openai/openai_responses_client_with_code_interpreter.py
+++ b/python/samples/getting_started/agents/openai/openai_responses_client_with_code_interpreter.py
@@ -4,9 +4,6 @@
from agent_framework import (
ChatAgent,
- CodeInterpreterToolCallContent,
- CodeInterpreterToolResultContent,
- Content,
HostedCodeInterpreterTool,
)
from agent_framework.openai import OpenAIResponsesClient
@@ -35,8 +32,8 @@ async def main() -> None:
print(f"Result: {result}\n")
for message in result.messages:
- code_blocks = [c for c in message.contents if isinstance(c, CodeInterpreterToolCallContent)]
- outputs = [c for c in message.contents if isinstance(c, CodeInterpreterToolResultContent)]
+ code_blocks = [c for c in message.contents if c.type == "code_interpreter_tool_input"]
+ outputs = [c for c in message.contents if c.type == "code_interpreter_tool_result"]
if code_blocks:
code_inputs = code_blocks[0].inputs or []
for content in code_inputs:
diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_file_search.py b/python/samples/getting_started/agents/openai/openai_responses_client_with_file_search.py
index 3bac4d2cab..3784c5a715 100644
--- a/python/samples/getting_started/agents/openai/openai_responses_client_with_file_search.py
+++ b/python/samples/getting_started/agents/openai/openai_responses_client_with_file_search.py
@@ -2,7 +2,7 @@
import asyncio
-from agent_framework import ChatAgent, HostedFileSearchTool, HostedVectorStoreContent
+from agent_framework import ChatAgent, Content, HostedFileSearchTool
from agent_framework.openai import OpenAIResponsesClient
"""
@@ -15,7 +15,7 @@
# Helper functions
-async def create_vector_store(client: OpenAIResponsesClient) -> tuple[str, HostedVectorStoreContent]:
+async def create_vector_store(client: OpenAIResponsesClient) -> tuple[str, Content]:
"""Create a vector store with sample documents."""
file = await client.client.files.create(
file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."), purpose="user_data"
@@ -28,7 +28,7 @@ async def create_vector_store(client: OpenAIResponsesClient) -> tuple[str, Hoste
if result.last_error is not None:
raise Exception(f"Vector store file processing failed with status: {result.last_error.message}")
- return file.id, HostedVectorStoreContent(vector_store_id=vector_store.id)
+ return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id)
async def delete_vector_store(client: OpenAIResponsesClient, file_id: str, vector_store_id: str) -> None:
@@ -55,7 +55,7 @@ async def main() -> None:
if stream:
print("Assistant: ", end="")
- async for chunk in agent.run_stream(message):
+ async for chunk in agent.run(message, stream=True):
if chunk.text:
print(chunk.text, end="")
print("")
diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_hosted_mcp.py b/python/samples/getting_started/agents/openai/openai_responses_client_with_hosted_mcp.py
index 264971d8e7..30a8e55881 100644
--- a/python/samples/getting_started/agents/openai/openai_responses_client_with_hosted_mcp.py
+++ b/python/samples/getting_started/agents/openai/openai_responses_client_with_hosted_mcp.py
@@ -29,10 +29,10 @@ async def handle_approvals_without_thread(query: str, agent: "AgentProtocol"):
f"User Input Request for function from {agent.name}: {user_input_needed.function_call.name}"
f" with arguments: {user_input_needed.function_call.arguments}"
)
- new_inputs.append(ChatMessage("assistant", [user_input_needed]))
+ new_inputs.append(ChatMessage(role="assistant", contents=[user_input_needed]))
user_approval = input("Approve function call? (y/n): ")
new_inputs.append(
- ChatMessage("user", [user_input_needed.to_function_approval_response(user_approval.lower() == "y")])
+ ChatMessage(role="user", contents=[user_input_needed.to_function_approval_response(user_approval.lower() == "y")])
)
result = await agent.run(new_inputs)
@@ -70,8 +70,8 @@ async def handle_approvals_with_thread_streaming(query: str, agent: "AgentProtoc
new_input_added = True
while new_input_added:
new_input_added = False
- new_input.append(ChatMessage("user", [query]))
- async for update in agent.run_stream(new_input, thread=thread, store=True):
+ new_input.append(ChatMessage(role="user", text=query))
+ async for update in agent.run(new_input, thread=thread, stream=True, options={"store": True}):
if update.user_input_requests:
for user_input_needed in update.user_input_requests:
print(
diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_local_mcp.py b/python/samples/getting_started/agents/openai/openai_responses_client_with_local_mcp.py
index e2709d2159..50ebcf9ad7 100644
--- a/python/samples/getting_started/agents/openai/openai_responses_client_with_local_mcp.py
+++ b/python/samples/getting_started/agents/openai/openai_responses_client_with_local_mcp.py
@@ -35,7 +35,7 @@ async def streaming_with_mcp(show_raw_stream: bool = False) -> None:
query1 = "How to create an Azure storage account using az cli?"
print(f"User: {query1}")
print(f"{agent.name}: ", end="")
- async for chunk in agent.run_stream(query1):
+ async for chunk in agent.run(query1, stream=True):
if show_raw_stream:
print("Streamed event: ", chunk.raw_representation.raw_representation) # type:ignore
elif chunk.text:
@@ -46,7 +46,7 @@ async def streaming_with_mcp(show_raw_stream: bool = False) -> None:
query2 = "What is Microsoft Agent Framework?"
print(f"User: {query2}")
print(f"{agent.name}: ", end="")
- async for chunk in agent.run_stream(query2):
+ async for chunk in agent.run(query2, stream=True):
if show_raw_stream:
print("Streamed event: ", chunk.raw_representation.raw_representation) # type:ignore
elif chunk.text:
diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_runtime_json_schema.py b/python/samples/getting_started/agents/openai/openai_responses_client_with_runtime_json_schema.py
index 9ed6afd11a..106a721e0f 100644
--- a/python/samples/getting_started/agents/openai/openai_responses_client_with_runtime_json_schema.py
+++ b/python/samples/getting_started/agents/openai/openai_responses_client_with_runtime_json_schema.py
@@ -74,8 +74,9 @@ async def streaming_example() -> None:
print(f"User: {query}")
chunks: list[str] = []
- async for chunk in agent.run_stream(
+ async for chunk in agent.run(
query,
+ stream=True,
options={
"response_format": {
"type": "json_schema",
diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_structured_output.py b/python/samples/getting_started/agents/openai/openai_responses_client_with_structured_output.py
index c893f271b1..a0b9a01a20 100644
--- a/python/samples/getting_started/agents/openai/openai_responses_client_with_structured_output.py
+++ b/python/samples/getting_started/agents/openai/openai_responses_client_with_structured_output.py
@@ -59,16 +59,16 @@ async def streaming_example() -> None:
query = "Tell me about Tokyo, Japan"
print(f"User: {query}")
- # Get structured response from streaming agent using AgentResponse.from_agent_response_generator
+ # Get structured response from streaming agent using AgentResponse.from_update_generator
# This method collects all streaming updates and combines them into a single AgentResponse
- result = await AgentResponse.from_agent_response_generator(
- agent.run_stream(query, options={"response_format": OutputStruct}),
+ result = await AgentResponse.from_update_generator(
+ agent.run(query, stream=True, options={"response_format": OutputStruct}),
output_format_type=OutputStruct,
)
# Access the structured output using the parsed value
if structured_data := result.value:
- print("Structured Output (from streaming with AgentResponse.from_agent_response_generator):")
+ print("Structured Output (from streaming with AgentResponse.from_update_generator):")
print(f"City: {structured_data.city}")
print(f"Description: {structured_data.description}")
else:
diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_with_web_search.py b/python/samples/getting_started/agents/openai/openai_responses_client_with_web_search.py
index 03ee48015f..24e0368512 100644
--- a/python/samples/getting_started/agents/openai/openai_responses_client_with_web_search.py
+++ b/python/samples/getting_started/agents/openai/openai_responses_client_with_web_search.py
@@ -34,7 +34,7 @@ async def main() -> None:
if stream:
print("Assistant: ", end="")
- async for chunk in agent.run_stream(message):
+ async for chunk in agent.run(message, stream=True):
if chunk.text:
print(chunk.text, end="")
print("")
diff --git a/python/samples/getting_started/chat_client/README.md b/python/samples/getting_started/chat_client/README.md
index 4b36865769..20060f691d 100644
--- a/python/samples/getting_started/chat_client/README.md
+++ b/python/samples/getting_started/chat_client/README.md
@@ -14,6 +14,7 @@ This folder contains simple examples demonstrating direct usage of various chat
| [`openai_assistants_client.py`](openai_assistants_client.py) | Direct usage of OpenAI Assistants Client for basic chat interactions with OpenAI assistants. |
| [`openai_chat_client.py`](openai_chat_client.py) | Direct usage of OpenAI Chat Client for chat interactions with OpenAI models. |
| [`openai_responses_client.py`](openai_responses_client.py) | Direct usage of OpenAI Responses Client for structured response generation with OpenAI models. |
+| [`custom_chat_client.py`](custom_chat_client.py) | Demonstrates how to create custom chat clients by extending the `BaseChatClient` class. Shows a `EchoingChatClient` implementation and how to integrate it with `ChatAgent` using the `as_agent()` method. |
## Environment Variables
@@ -37,4 +38,4 @@ Depending on which client you're using, set the appropriate environment variable
- `OLLAMA_HOST`: Your Ollama server URL (defaults to `http://localhost:11434` if not set)
- `OLLAMA_MODEL_ID`: The Ollama model to use for chat (e.g., `llama3.2`, `llama2`, `codellama`)
-> **Note**: For Ollama, ensure you have Ollama installed and running locally with at least one model downloaded. Visit [https://ollama.com/](https://ollama.com/) for installation instructions.
\ No newline at end of file
+> **Note**: For Ollama, ensure you have Ollama installed and running locally with at least one model downloaded. Visit [https://ollama.com/](https://ollama.com/) for installation instructions.
diff --git a/python/samples/getting_started/chat_client/azure_ai_chat_client.py b/python/samples/getting_started/chat_client/azure_ai_chat_client.py
index 97aa015f13..b699add89e 100644
--- a/python/samples/getting_started/chat_client/azure_ai_chat_client.py
+++ b/python/samples/getting_started/chat_client/azure_ai_chat_client.py
@@ -36,7 +36,7 @@ async def main() -> None:
print(f"User: {message}")
if stream:
print("Assistant: ", end="")
- async for chunk in client.get_streaming_response(message, tools=get_weather):
+ async for chunk in client.get_response(message, tools=get_weather, stream=True):
if str(chunk):
print(str(chunk), end="")
print("")
diff --git a/python/samples/getting_started/chat_client/azure_assistants_client.py b/python/samples/getting_started/chat_client/azure_assistants_client.py
index 99f4de5b9c..599593f54c 100644
--- a/python/samples/getting_started/chat_client/azure_assistants_client.py
+++ b/python/samples/getting_started/chat_client/azure_assistants_client.py
@@ -36,7 +36,7 @@ async def main() -> None:
print(f"User: {message}")
if stream:
print("Assistant: ", end="")
- async for chunk in client.get_streaming_response(message, tools=get_weather):
+ async for chunk in client.get_response(message, tools=get_weather, stream=True):
if str(chunk):
print(str(chunk), end="")
print("")
diff --git a/python/samples/getting_started/chat_client/azure_chat_client.py b/python/samples/getting_started/chat_client/azure_chat_client.py
index 77b3358a39..13a299ca30 100644
--- a/python/samples/getting_started/chat_client/azure_chat_client.py
+++ b/python/samples/getting_started/chat_client/azure_chat_client.py
@@ -36,7 +36,7 @@ async def main() -> None:
print(f"User: {message}")
if stream:
print("Assistant: ", end="")
- async for chunk in client.get_streaming_response(message, tools=get_weather):
+ async for chunk in client.get_response(message, tools=get_weather, stream=True):
if str(chunk):
print(str(chunk), end="")
print("")
diff --git a/python/samples/getting_started/chat_client/azure_responses_client.py b/python/samples/getting_started/chat_client/azure_responses_client.py
index 17a1ab335a..a0c3fa69df 100644
--- a/python/samples/getting_started/chat_client/azure_responses_client.py
+++ b/python/samples/getting_started/chat_client/azure_responses_client.py
@@ -42,21 +42,19 @@ async def main() -> None:
stream = True
print(f"User: {message}")
if stream:
- response = await ChatResponse.from_update_generator(
- client.get_streaming_response(message, tools=get_weather, options={"response_format": OutputStruct}),
+ response = await ChatResponse.from_chat_response_generator(
+ client.get_response(message, tools=get_weather, options={"response_format": OutputStruct}, stream=True),
output_format_type=OutputStruct,
)
- try:
- result = response.value
+ if result := response.try_parse_value(OutputStruct):
print(f"Assistant: {result}")
- except Exception:
+ else:
print(f"Assistant: {response.text}")
else:
response = await client.get_response(message, tools=get_weather, options={"response_format": OutputStruct})
- try:
- result = response.value
+ if result := response.try_parse_value(OutputStruct):
print(f"Assistant: {result}")
- except Exception:
+ else:
print(f"Assistant: {response.text}")
diff --git a/python/samples/getting_started/agents/custom/custom_chat_client.py b/python/samples/getting_started/chat_client/custom_chat_client.py
similarity index 65%
rename from python/samples/getting_started/agents/custom/custom_chat_client.py
rename to python/samples/getting_started/chat_client/custom_chat_client.py
index a6c38fcbca..b55b7a38d6 100644
--- a/python/samples/getting_started/agents/custom/custom_chat_client.py
+++ b/python/samples/getting_started/chat_client/custom_chat_client.py
@@ -3,40 +3,54 @@
import asyncio
import random
import sys
-from collections.abc import AsyncIterable, MutableSequence
-from typing import Any, ClassVar, Generic
+from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence
+from typing import Any, ClassVar, Generic, TypedDict
from agent_framework import (
BaseChatClient,
ChatMessage,
+ ChatMiddlewareLayer,
+ ChatOptions,
ChatResponse,
ChatResponseUpdate,
Content,
- use_chat_middleware,
- use_function_invocation,
+ FunctionInvocationLayer,
+ ResponseStream,
+ Role,
)
from agent_framework._clients import TOptions_co
+from agent_framework.observability import ChatTelemetryLayer
+if sys.version_info >= (3, 13):
+ from typing import TypeVar
+else:
+ from typing_extensions import TypeVar
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
+
"""
Custom Chat Client Implementation Example
-This sample demonstrates implementing a custom chat client by extending BaseChatClient class,
-showing integration with ChatAgent and both streaming and non-streaming responses.
+This sample demonstrates implementing a custom chat client and optionally composing
+middleware, telemetry, and function invocation layers explicitly.
"""
+TOptions_co = TypeVar(
+ "TOptions_co",
+ bound=TypedDict, # type: ignore[valid-type]
+ default="ChatOptions",
+ covariant=True,
+)
+
-@use_function_invocation
-@use_chat_middleware
class EchoingChatClient(BaseChatClient[TOptions_co], Generic[TOptions_co]):
"""A custom chat client that echoes messages back with modifications.
This demonstrates how to implement a custom chat client by extending BaseChatClient
- and implementing the required _inner_get_response() and _inner_get_streaming_response() methods.
+ and implementing the required _inner_get_response() method.
"""
OTEL_PROVIDER_NAME: ClassVar[str] = "EchoingChatClient"
@@ -52,13 +66,14 @@ def __init__(self, *, prefix: str = "Echo:", **kwargs: Any) -> None:
self.prefix = prefix
@override
- async def _inner_get_response(
+ def _inner_get_response(
self,
*,
- messages: MutableSequence[ChatMessage],
- options: dict[str, Any],
+ messages: Sequence[ChatMessage],
+ stream: bool = False,
+ options: Mapping[str, Any],
**kwargs: Any,
- ) -> ChatResponse:
+ ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
"""Echo back the user's message with a prefix."""
if not messages:
response_text = "No messages to echo!"
@@ -66,7 +81,7 @@ async def _inner_get_response(
# Echo the last user message
last_user_message = None
for message in reversed(messages):
- if message.role == "user":
+ if message.role == Role.USER:
last_user_message = message
break
@@ -75,39 +90,46 @@ async def _inner_get_response(
else:
response_text = f"{self.prefix} [No text message found]"
- response_message = ChatMessage("assistant", [Content.from_text(text=response_text)])
+ response_message = ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text(response_text)])
- return ChatResponse(
+ response = ChatResponse(
messages=[response_message],
model_id="echo-model-v1",
response_id=f"echo-resp-{random.randint(1000, 9999)}",
)
- @override
- async def _inner_get_streaming_response(
- self,
- *,
- messages: MutableSequence[ChatMessage],
- options: dict[str, Any],
- **kwargs: Any,
- ) -> AsyncIterable[ChatResponseUpdate]:
- """Stream back the echoed message character by character."""
- # Get the complete response first
- response = await self._inner_get_response(messages=messages, options=options, **kwargs)
+ if not stream:
+
+ async def _get_response() -> ChatResponse:
+ return response
- if response.messages:
- response_text = response.messages[0].text or ""
+ return _get_response()
- # Stream character by character
- for char in response_text:
+ async def _stream() -> AsyncIterable[ChatResponseUpdate]:
+ response_text_local = response_message.text or ""
+ for char in response_text_local:
yield ChatResponseUpdate(
- contents=[Content.from_text(text=char)],
- role="assistant",
+ contents=[Content.from_text(char)],
+ role=Role.ASSISTANT,
response_id=f"echo-stream-resp-{random.randint(1000, 9999)}",
model_id="echo-model-v1",
)
await asyncio.sleep(0.05)
+ return ResponseStream(_stream(), finalizer=lambda updates: response)
+
+
+class EchoingChatClientWithLayers( # type: ignore[misc,type-var]
+ ChatMiddlewareLayer[TOptions_co],
+ ChatTelemetryLayer[TOptions_co],
+ FunctionInvocationLayer[TOptions_co],
+ EchoingChatClient[TOptions_co],
+ Generic[TOptions_co],
+):
+ """Echoing chat client that explicitly composes middleware, telemetry, and function layers."""
+
+ OTEL_PROVIDER_NAME: ClassVar[str] = "EchoingChatClientWithLayers"
+
async def main() -> None:
"""Demonstrates how to implement and use a custom chat client with ChatAgent."""
@@ -116,7 +138,7 @@ async def main() -> None:
# Create the custom chat client
print("--- EchoingChatClient Example ---")
- echo_client = EchoingChatClient(prefix="🔊 Echo:")
+ echo_client = EchoingChatClientWithLayers(prefix="🔊 Echo:")
# Use the chat client directly
print("Using chat client directly:")
@@ -141,7 +163,7 @@ async def main() -> None:
query2 = "Stream this message back to me"
print(f"\nUser: {query2}")
print("Agent: ", end="", flush=True)
- async for chunk in echo_agent.run_stream(query2):
+ async for chunk in echo_agent.run(query2, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print()
diff --git a/python/samples/getting_started/chat_client/openai_assistants_client.py b/python/samples/getting_started/chat_client/openai_assistants_client.py
index 88aec44ed2..9ff13f39ab 100644
--- a/python/samples/getting_started/chat_client/openai_assistants_client.py
+++ b/python/samples/getting_started/chat_client/openai_assistants_client.py
@@ -34,7 +34,7 @@ async def main() -> None:
print(f"User: {message}")
if stream:
print("Assistant: ", end="")
- async for chunk in client.get_streaming_response(message, tools=get_weather):
+ async for chunk in client.get_response(message, tools=get_weather, stream=True):
if str(chunk):
print(str(chunk), end="")
print("")
diff --git a/python/samples/getting_started/chat_client/openai_chat_client.py b/python/samples/getting_started/chat_client/openai_chat_client.py
index da50ae59bf..279d3eb186 100644
--- a/python/samples/getting_started/chat_client/openai_chat_client.py
+++ b/python/samples/getting_started/chat_client/openai_chat_client.py
@@ -34,7 +34,7 @@ async def main() -> None:
print(f"User: {message}")
if stream:
print("Assistant: ", end="")
- async for chunk in client.get_streaming_response(message, tools=get_weather):
+ async for chunk in client.get_response(message, tools=get_weather, stream=True):
if chunk.text:
print(chunk.text, end="")
print("")
diff --git a/python/samples/getting_started/chat_client/openai_responses_client.py b/python/samples/getting_started/chat_client/openai_responses_client.py
index c9d476faa3..a84066ea87 100644
--- a/python/samples/getting_started/chat_client/openai_responses_client.py
+++ b/python/samples/getting_started/chat_client/openai_responses_client.py
@@ -30,14 +30,14 @@ def get_weather(
async def main() -> None:
client = OpenAIResponsesClient()
message = "What's the weather in Amsterdam and in Paris?"
- stream = False
+ stream = True
print(f"User: {message}")
if stream:
print("Assistant: ", end="")
- async for chunk in client.get_streaming_response(message, tools=get_weather):
- if chunk.text:
- print(chunk.text, end="")
- print("")
+ response = client.get_response(message, stream=True, tools=get_weather)
+ # TODO: review names of the methods, could be related to things like HTTP clients?
+ response.with_update_hook(lambda chunk: print(chunk.text, end=""))
+ await response.get_final_response()
else:
response = await client.get_response(message, tools=get_weather)
print(f"Assistant: {response}")
diff --git a/python/samples/getting_started/context_providers/azure_ai_search/azure_ai_with_search_context_agentic.py b/python/samples/getting_started/context_providers/azure_ai_search/azure_ai_with_search_context_agentic.py
index a1c389fb2a..6e3e40a216 100644
--- a/python/samples/getting_started/context_providers/azure_ai_search/azure_ai_with_search_context_agentic.py
+++ b/python/samples/getting_started/context_providers/azure_ai_search/azure_ai_with_search_context_agentic.py
@@ -130,7 +130,7 @@ async def main() -> None:
print("Agent: ", end="", flush=True)
# Stream response
- async for chunk in agent.run_stream(user_input):
+ async for chunk in agent.run(user_input, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
diff --git a/python/samples/getting_started/context_providers/azure_ai_search/azure_ai_with_search_context_semantic.py b/python/samples/getting_started/context_providers/azure_ai_search/azure_ai_with_search_context_semantic.py
index a504de7447..4fce526a1f 100644
--- a/python/samples/getting_started/context_providers/azure_ai_search/azure_ai_with_search_context_semantic.py
+++ b/python/samples/getting_started/context_providers/azure_ai_search/azure_ai_with_search_context_semantic.py
@@ -86,7 +86,7 @@ async def main() -> None:
print("Agent: ", end="", flush=True)
# Stream response
- async for chunk in agent.run_stream(user_input):
+ async for chunk in agent.run(user_input, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
diff --git a/python/samples/getting_started/devui/weather_agent_azure/agent.py b/python/samples/getting_started/devui/weather_agent_azure/agent.py
index 71525c24a1..b4dd667bed 100644
--- a/python/samples/getting_started/devui/weather_agent_azure/agent.py
+++ b/python/samples/getting_started/devui/weather_agent_azure/agent.py
@@ -14,6 +14,8 @@
ChatResponseUpdate,
Content,
FunctionInvocationContext,
+ Role,
+ TextContent,
chat_middleware,
function_middleware,
tool,
@@ -42,7 +44,7 @@ async def security_filter_middleware(
# Check only the last message (most recent user input)
last_message = context.messages[-1] if context.messages else None
- if last_message and last_message.role == "user" and last_message.text:
+ if last_message and last_message.role == Role.USER and last_message.text:
message_lower = last_message.text.lower()
for term in blocked_terms:
if term in message_lower:
@@ -52,12 +54,12 @@ async def security_filter_middleware(
"or other sensitive data."
)
- if context.is_streaming:
+ if context.stream:
# Streaming mode: return async generator
async def blocked_stream() -> AsyncIterable[ChatResponseUpdate]:
yield ChatResponseUpdate(
contents=[Content.from_text(text=error_message)],
- role="assistant",
+ role=Role.ASSISTANT,
)
context.result = blocked_stream()
@@ -66,7 +68,7 @@ async def blocked_stream() -> AsyncIterable[ChatResponseUpdate]:
context.result = ChatResponse(
messages=[
ChatMessage(
- role="assistant",
+ role=Role.ASSISTANT,
text=error_message,
)
]
diff --git a/python/samples/getting_started/durabletask/01_single_agent/worker.py b/python/samples/getting_started/durabletask/01_single_agent/worker.py
index 03fc5a667f..d2212c9ddb 100644
--- a/python/samples/getting_started/durabletask/01_single_agent/worker.py
+++ b/python/samples/getting_started/durabletask/01_single_agent/worker.py
@@ -3,8 +3,8 @@
This worker registers agents as durable entities and continuously listens for requests.
The worker should run as a background service, processing incoming agent requests.
-Prerequisites:
-- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME
+Prerequisites:
+- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME
(plus AZURE_OPENAI_API_KEY or Azure CLI authentication)
- Start a Durable Task Scheduler (e.g., using Docker)
"""
@@ -25,7 +25,7 @@
def create_joker_agent() -> ChatAgent:
"""Create the Joker agent using Azure OpenAI.
-
+
Returns:
ChatAgent: The configured Joker agent
"""
@@ -41,12 +41,12 @@ def get_worker(
log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker.
-
+
Args:
taskhub: Task hub name (defaults to TASKHUB env var or "default")
endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080")
log_handler: Optional logging handler for worker logging
-
+
Returns:
Configured DurableTaskSchedulerWorker instance
"""
@@ -69,10 +69,10 @@ def get_worker(
def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""Set up the worker with agents registered.
-
+
Args:
worker: The DurableTaskSchedulerWorker instance
-
+
Returns:
DurableAIAgentWorker with agents registered
"""
diff --git a/python/samples/getting_started/durabletask/02_multi_agent/worker.py b/python/samples/getting_started/durabletask/02_multi_agent/worker.py
index 968d8fc997..7ea7ad840d 100644
--- a/python/samples/getting_started/durabletask/02_multi_agent/worker.py
+++ b/python/samples/getting_started/durabletask/02_multi_agent/worker.py
@@ -4,8 +4,8 @@
with their own specialized tools. This demonstrates how to host multiple agents
with different capabilities in a single worker process.
-Prerequisites:
-- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME
+Prerequisites:
+- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_CHAT_DEPLOYMENT_NAME
(plus AZURE_OPENAI_API_KEY or Azure CLI authentication)
- Start a Durable Task Scheduler (e.g., using Docker)
"""
@@ -15,6 +15,7 @@
import os
from typing import Any
+from agent_framework import tool
from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentWorker
from azure.identity import AzureCliCredential, DefaultAzureCredential
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
@@ -28,6 +29,7 @@
MATH_AGENT_NAME = "MathAgent"
+@tool
def get_weather(location: str) -> dict[str, Any]:
"""Get current weather for a location."""
logger.info(f"🔧 [TOOL CALLED] get_weather(location={location})")
@@ -41,11 +43,10 @@ def get_weather(location: str) -> dict[str, Any]:
return result
+@tool
def calculate_tip(bill_amount: float, tip_percentage: float = 15.0) -> dict[str, Any]:
"""Calculate tip amount and total bill."""
- logger.info(
- f"🔧 [TOOL CALLED] calculate_tip(bill_amount={bill_amount}, tip_percentage={tip_percentage})"
- )
+ logger.info(f"🔧 [TOOL CALLED] calculate_tip(bill_amount={bill_amount}, tip_percentage={tip_percentage})")
tip = bill_amount * (tip_percentage / 100)
total = bill_amount + tip
result = {
@@ -60,7 +61,7 @@ def calculate_tip(bill_amount: float, tip_percentage: float = 15.0) -> dict[str,
def create_weather_agent():
"""Create the Weather agent using Azure OpenAI.
-
+
Returns:
ChatAgent: The configured Weather agent with weather tool
"""
@@ -73,7 +74,7 @@ def create_weather_agent():
def create_math_agent():
"""Create the Math agent using Azure OpenAI.
-
+
Returns:
ChatAgent: The configured Math agent with calculation tools
"""
@@ -85,17 +86,15 @@ def create_math_agent():
def get_worker(
- taskhub: str | None = None,
- endpoint: str | None = None,
- log_handler: logging.Handler | None = None
+ taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker.
-
+
Args:
taskhub: Task hub name (defaults to TASKHUB env var or "default")
endpoint: Scheduler endpoint (defaults to ENDPOINT env var or "http://localhost:8080")
log_handler: Optional logging handler for worker logging
-
+
Returns:
Configured DurableTaskSchedulerWorker instance
"""
@@ -112,16 +111,16 @@ def get_worker(
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
- log_handler=log_handler
+ log_handler=log_handler,
)
def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""Set up the worker with multiple agents registered.
-
+
Args:
worker: The DurableTaskSchedulerWorker instance
-
+
Returns:
DurableAIAgentWorker with agents registered
"""
diff --git a/python/samples/getting_started/durabletask/03_single_agent_streaming/tools.py b/python/samples/getting_started/durabletask/03_single_agent_streaming/tools.py
index 29be74a846..be4900860a 100644
--- a/python/samples/getting_started/durabletask/03_single_agent_streaming/tools.py
+++ b/python/samples/getting_started/durabletask/03_single_agent_streaming/tools.py
@@ -4,10 +4,12 @@
In a real application, these would call actual weather and events APIs.
"""
-
from typing import Annotated
+from agent_framework import tool
+
+@tool
def get_weather_forecast(
destination: Annotated[str, "The destination city or location"],
date: Annotated[str, 'The date for the forecast (e.g., "2025-01-15" or "next Monday")'],
@@ -64,6 +66,7 @@ def get_weather_forecast(
Recommendation: {recommendation}"""
+@tool
def get_local_events(
destination: Annotated[str, "The destination city or location"],
date: Annotated[str, 'The date to search for events (e.g., "2025-01-15" or "next week")'],
diff --git a/python/samples/getting_started/middleware/agent_and_run_level_middleware.py b/python/samples/getting_started/middleware/agent_and_run_level_middleware.py
index ff4735c01c..32fd7a2e52 100644
--- a/python/samples/getting_started/middleware/agent_and_run_level_middleware.py
+++ b/python/samples/getting_started/middleware/agent_and_run_level_middleware.py
@@ -18,7 +18,7 @@
from pydantic import Field
"""
-Agent-Level and Run-Level Middleware Example
+Agent-Level and Run-Level MiddlewareTypes Example
This sample demonstrates the difference between agent-level and run-level middleware:
@@ -107,7 +107,7 @@ async def debugging_middleware(
"""Run-level debugging middleware for troubleshooting specific runs."""
print("[Debug] Debug mode enabled for this run")
print(f"[Debug] Messages count: {len(context.messages)}")
- print(f"[Debug] Is streaming: {context.is_streaming}")
+ print(f"[Debug] Is streaming: {context.stream}")
# Log existing metadata from agent middleware
if context.metadata:
@@ -163,7 +163,7 @@ async def function_logging_middleware(
async def main() -> None:
"""Example demonstrating agent-level and run-level middleware."""
- print("=== Agent-Level and Run-Level Middleware Example ===\n")
+ print("=== Agent-Level and Run-Level MiddlewareTypes Example ===\n")
# For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred
# authentication option.
diff --git a/python/samples/getting_started/middleware/chat_middleware.py b/python/samples/getting_started/middleware/chat_middleware.py
index 548b1186fa..e7e807f27e 100644
--- a/python/samples/getting_started/middleware/chat_middleware.py
+++ b/python/samples/getting_started/middleware/chat_middleware.py
@@ -18,7 +18,7 @@
from pydantic import Field
"""
-Chat Middleware Example
+Chat MiddlewareTypes Example
This sample demonstrates how to use chat middleware to observe and override
inputs sent to AI models. Chat middleware intercepts chat requests before they reach
@@ -31,8 +31,8 @@
The example covers:
- Class-based chat middleware inheriting from ChatMiddleware
- Function-based chat middleware with @chat_middleware decorator
-- Middleware registration at agent level (applies to all runs)
-- Middleware registration at run level (applies to specific run only)
+- MiddlewareTypes registration at agent level (applies to all runs)
+- MiddlewareTypes registration at run level (applies to specific run only)
"""
@@ -137,7 +137,7 @@ async def security_and_override_middleware(
async def class_based_chat_middleware() -> None:
"""Demonstrate class-based middleware at agent level."""
print("\n" + "=" * 60)
- print("Class-based Chat Middleware (Agent Level)")
+ print("Class-based Chat MiddlewareTypes (Agent Level)")
print("=" * 60)
# For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred
@@ -161,7 +161,7 @@ async def class_based_chat_middleware() -> None:
async def function_based_chat_middleware() -> None:
"""Demonstrate function-based middleware at agent level."""
print("\n" + "=" * 60)
- print("Function-based Chat Middleware (Agent Level)")
+ print("Function-based Chat MiddlewareTypes (Agent Level)")
print("=" * 60)
async with (
@@ -191,7 +191,7 @@ async def function_based_chat_middleware() -> None:
async def run_level_middleware() -> None:
"""Demonstrate middleware registration at run level."""
print("\n" + "=" * 60)
- print("Run-level Chat Middleware")
+ print("Run-level Chat MiddlewareTypes")
print("=" * 60)
async with (
@@ -204,14 +204,14 @@ async def run_level_middleware() -> None:
) as agent,
):
# Scenario 1: Run without any middleware
- print("\n--- Scenario 1: No Middleware ---")
+ print("\n--- Scenario 1: No MiddlewareTypes ---")
query = "What's the weather in Tokyo?"
print(f"User: {query}")
result = await agent.run(query)
print(f"Response: {result.text if result.text else 'No response'}")
# Scenario 2: Run with specific middleware for this call only (both enhancement and security)
- print("\n--- Scenario 2: With Run-level Middleware ---")
+ print("\n--- Scenario 2: With Run-level MiddlewareTypes ---")
print(f"User: {query}")
result = await agent.run(
query,
@@ -223,7 +223,7 @@ async def run_level_middleware() -> None:
print(f"Response: {result.text if result.text else 'No response'}")
# Scenario 3: Security test with run-level middleware
- print("\n--- Scenario 3: Security Test with Run-level Middleware ---")
+ print("\n--- Scenario 3: Security Test with Run-level MiddlewareTypes ---")
query = "Can you help me with my secret API key?"
print(f"User: {query}")
result = await agent.run(
@@ -235,7 +235,7 @@ async def run_level_middleware() -> None:
async def main() -> None:
"""Run all chat middleware examples."""
- print("Chat Middleware Examples")
+ print("Chat MiddlewareTypes Examples")
print("========================")
await class_based_chat_middleware()
diff --git a/python/samples/getting_started/middleware/class_based_middleware.py b/python/samples/getting_started/middleware/class_based_middleware.py
index 63ccfc998b..65fa279f19 100644
--- a/python/samples/getting_started/middleware/class_based_middleware.py
+++ b/python/samples/getting_started/middleware/class_based_middleware.py
@@ -20,7 +20,7 @@
from pydantic import Field
"""
-Class-based Middleware Example
+Class-based MiddlewareTypes Example
This sample demonstrates how to implement middleware using class-based approach by inheriting
from AgentMiddleware and FunctionMiddleware base classes. The example includes:
@@ -95,7 +95,7 @@ async def process(
async def main() -> None:
"""Example demonstrating class-based middleware."""
- print("=== Class-based Middleware Example ===")
+ print("=== Class-based MiddlewareTypes Example ===")
# For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred
# authentication option.
diff --git a/python/samples/getting_started/middleware/decorator_middleware.py b/python/samples/getting_started/middleware/decorator_middleware.py
index 0ac600fd19..f16407918c 100644
--- a/python/samples/getting_started/middleware/decorator_middleware.py
+++ b/python/samples/getting_started/middleware/decorator_middleware.py
@@ -12,7 +12,7 @@
from azure.identity.aio import AzureCliCredential
"""
-Decorator Middleware Example
+Decorator MiddlewareTypes Example
This sample demonstrates how to use @agent_middleware and @function_middleware decorators
to explicitly mark middleware functions without requiring type annotations.
@@ -52,22 +52,22 @@ def get_current_time() -> str:
@agent_middleware # Decorator marks this as agent middleware - no type annotations needed
async def simple_agent_middleware(context, next): # type: ignore - parameters intentionally untyped to demonstrate decorator functionality
"""Agent middleware that runs before and after agent execution."""
- print("[Agent Middleware] Before agent execution")
+ print("[Agent MiddlewareTypes] Before agent execution")
await next(context)
- print("[Agent Middleware] After agent execution")
+ print("[Agent MiddlewareTypes] After agent execution")
@function_middleware # Decorator marks this as function middleware - no type annotations needed
async def simple_function_middleware(context, next): # type: ignore - parameters intentionally untyped to demonstrate decorator functionality
"""Function middleware that runs before and after function calls."""
- print(f"[Function Middleware] Before calling: {context.function.name}") # type: ignore
+ print(f"[Function MiddlewareTypes] Before calling: {context.function.name}") # type: ignore
await next(context)
- print(f"[Function Middleware] After calling: {context.function.name}") # type: ignore
+ print(f"[Function MiddlewareTypes] After calling: {context.function.name}") # type: ignore
async def main() -> None:
"""Example demonstrating decorator-based middleware."""
- print("=== Decorator Middleware Example ===")
+ print("=== Decorator MiddlewareTypes Example ===")
# For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred
# authentication option.
diff --git a/python/samples/getting_started/middleware/exception_handling_with_middleware.py b/python/samples/getting_started/middleware/exception_handling_with_middleware.py
index 5efe9fe662..bc752e3615 100644
--- a/python/samples/getting_started/middleware/exception_handling_with_middleware.py
+++ b/python/samples/getting_started/middleware/exception_handling_with_middleware.py
@@ -10,7 +10,7 @@
from pydantic import Field
"""
-Exception Handling with Middleware
+Exception Handling with MiddlewareTypes
This sample demonstrates how to use middleware for centralized exception handling in function calls.
The example shows:
@@ -54,7 +54,7 @@ async def exception_handling_middleware(
async def main() -> None:
"""Example demonstrating exception handling with middleware."""
- print("=== Exception Handling Middleware Example ===")
+ print("=== Exception Handling MiddlewareTypes Example ===")
# For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred
# authentication option.
diff --git a/python/samples/getting_started/middleware/function_based_middleware.py b/python/samples/getting_started/middleware/function_based_middleware.py
index d58ac46c87..21defef491 100644
--- a/python/samples/getting_started/middleware/function_based_middleware.py
+++ b/python/samples/getting_started/middleware/function_based_middleware.py
@@ -16,7 +16,7 @@
from pydantic import Field
"""
-Function-based Middleware Example
+Function-based MiddlewareTypes Example
This sample demonstrates how to implement middleware using simple async functions instead of classes.
The example includes:
@@ -80,7 +80,7 @@ async def logging_function_middleware(
async def main() -> None:
"""Example demonstrating function-based middleware."""
- print("=== Function-based Middleware Example ===")
+ print("=== Function-based MiddlewareTypes Example ===")
# For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred
# authentication option.
diff --git a/python/samples/getting_started/middleware/middleware_termination.py b/python/samples/getting_started/middleware/middleware_termination.py
index cbd82897b4..ea32bc606b 100644
--- a/python/samples/getting_started/middleware/middleware_termination.py
+++ b/python/samples/getting_started/middleware/middleware_termination.py
@@ -17,7 +17,7 @@
from pydantic import Field
"""
-Middleware Termination Example
+MiddlewareTypes Termination Example
This sample demonstrates how middleware can terminate execution using the `context.terminate` flag.
The example includes:
@@ -40,7 +40,7 @@ def get_weather(
class PreTerminationMiddleware(AgentMiddleware):
- """Middleware that terminates execution before calling the agent."""
+ """MiddlewareTypes that terminates execution before calling the agent."""
def __init__(self, blocked_words: list[str]):
self.blocked_words = [word.lower() for word in blocked_words]
@@ -79,7 +79,7 @@ async def process(
class PostTerminationMiddleware(AgentMiddleware):
- """Middleware that allows processing but terminates after reaching max responses across multiple runs."""
+ """MiddlewareTypes that allows processing but terminates after reaching max responses across multiple runs."""
def __init__(self, max_responses: int = 1):
self.max_responses = max_responses
@@ -109,7 +109,7 @@ async def process(
async def pre_termination_middleware() -> None:
"""Demonstrate pre-termination middleware that blocks requests with certain words."""
- print("\n--- Example 1: Pre-termination Middleware ---")
+ print("\n--- Example 1: Pre-termination MiddlewareTypes ---")
async with (
AzureCliCredential() as credential,
AzureAIAgentClient(credential=credential).as_agent(
@@ -136,7 +136,7 @@ async def pre_termination_middleware() -> None:
async def post_termination_middleware() -> None:
"""Demonstrate post-termination middleware that limits responses across multiple runs."""
- print("\n--- Example 2: Post-termination Middleware ---")
+ print("\n--- Example 2: Post-termination MiddlewareTypes ---")
async with (
AzureCliCredential() as credential,
AzureAIAgentClient(credential=credential).as_agent(
@@ -170,7 +170,7 @@ async def post_termination_middleware() -> None:
async def main() -> None:
"""Example demonstrating middleware termination functionality."""
- print("=== Middleware Termination Example ===")
+ print("=== MiddlewareTypes Termination Example ===")
await pre_termination_middleware()
await post_termination_middleware()
diff --git a/python/samples/getting_started/middleware/override_result_with_middleware.py b/python/samples/getting_started/middleware/override_result_with_middleware.py
index fe55f993ed..06351d1803 100644
--- a/python/samples/getting_started/middleware/override_result_with_middleware.py
+++ b/python/samples/getting_started/middleware/override_result_with_middleware.py
@@ -1,7 +1,8 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
-from collections.abc import AsyncIterable, Awaitable, Callable
+import re
+from collections.abc import Awaitable, Callable
from random import randint
from typing import Annotated
@@ -9,16 +10,19 @@
AgentResponse,
AgentResponseUpdate,
AgentRunContext,
+ ChatContext,
ChatMessage,
- Content,
+ ChatResponse,
+ ChatResponseUpdate,
+ ResponseStream,
+ Role,
tool,
)
-from agent_framework.azure import AzureAIAgentClient
-from azure.identity.aio import AzureCliCredential
+from agent_framework.openai import OpenAIResponsesClient
from pydantic import Field
"""
-Result Override with Middleware (Regular and Streaming)
+Result Override with MiddlewareTypes (Regular and Streaming)
This sample demonstrates how to use middleware to intercept and modify function results
after execution, supporting both regular and streaming agent responses. The example shows:
@@ -26,7 +30,7 @@
- How to execute the original function first and then modify its result
- Replacing function outputs with custom messages or transformed data
- Using middleware for result filtering, formatting, or enhancement
-- Detecting streaming vs non-streaming execution using context.is_streaming
+- Detecting streaming vs non-streaming execution using context.stream
- Overriding streaming results with custom async generators
The weather override middleware lets the original weather function execute normally,
@@ -45,10 +49,8 @@ def get_weather(
return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C."
-async def weather_override_middleware(
- context: AgentRunContext, next: Callable[[AgentRunContext], Awaitable[None]]
-) -> None:
- """Middleware that overrides weather results for both streaming and non-streaming cases."""
+async def weather_override_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None:
+ """Chat middleware that overrides weather results for both streaming and non-streaming cases."""
# Let the original agent execution complete first
await next(context)
@@ -57,56 +59,159 @@ async def weather_override_middleware(
if context.result is not None:
# Create custom weather message
chunks = [
- "Weather Advisory - ",
"due to special atmospheric conditions, ",
"all locations are experiencing perfect weather today! ",
"Temperature is a comfortable 22°C with gentle breezes. ",
"Perfect day for outdoor activities!",
]
- if context.is_streaming:
- # For streaming: create an async generator that yields chunks
- async def override_stream() -> AsyncIterable[AgentResponseUpdate]:
- for chunk in chunks:
- yield AgentResponseUpdate(contents=[Content.from_text(text=chunk)])
+ if context.stream and isinstance(context.result, ResponseStream):
+ index = {"value": 0}
+
+ def _update_hook(update: ChatResponseUpdate) -> ChatResponseUpdate:
+ for content in update.contents or []:
+ if not content.text:
+ continue
+ content.text = f"Weather Advisory: [{index['value']}] {content.text}"
+ index["value"] += 1
+ return update
- context.result = override_stream()
+ context.result.with_update_hook(_update_hook)
else:
- # For non-streaming: just replace with the string message
- custom_message = "".join(chunks)
- context.result = AgentResponse(messages=[ChatMessage("assistant", [custom_message])])
+ # For non-streaming: just replace with a new message
+ current_text = context.result.text or ""
+ custom_message = f"Weather Advisory: [0] {''.join(chunks)} Original message was: {current_text}"
+ context.result = ChatResponse(messages=[ChatMessage(role=Role.ASSISTANT, text=custom_message)])
+
+
+async def validate_weather_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None:
+ """Chat middleware that simulates result validation for both streaming and non-streaming cases."""
+ await next(context)
+
+ validation_note = "Validation: weather data verified."
+
+ if context.result is None:
+ return
+
+ if context.stream and isinstance(context.result, ResponseStream):
+
+ def _append_validation_note(response: ChatResponse) -> ChatResponse:
+ response.messages.append(ChatMessage(role=Role.ASSISTANT, text=validation_note))
+ return response
+
+ context.result.with_finalizer(_append_validation_note)
+ elif isinstance(context.result, ChatResponse):
+ context.result.messages.append(ChatMessage(role=Role.ASSISTANT, text=validation_note))
+
+
+async def agent_cleanup_middleware(
+ context: AgentRunContext, next: Callable[[AgentRunContext], Awaitable[None]]
+) -> None:
+ """Agent middleware that validates chat middleware effects and cleans the result."""
+ await next(context)
+
+ if context.result is None:
+ return
+
+ validation_note = "Validation: weather data verified."
+
+ state = {"found_prefix": False}
+
+ def _sanitize(response: AgentResponse) -> AgentResponse:
+ found_prefix = state["found_prefix"]
+ found_validation = False
+ cleaned_messages: list[ChatMessage] = []
+
+ for message in response.messages:
+ text = message.text
+ if text is None:
+ cleaned_messages.append(message)
+ continue
+
+ if validation_note in text:
+ found_validation = True
+ text = text.replace(validation_note, "").strip()
+ if not text:
+ continue
+
+ if "Weather Advisory:" in text:
+ found_prefix = True
+ text = text.replace("Weather Advisory:", "")
+
+ text = re.sub(r"\[\d+\]\s*", "", text)
+
+ cleaned_messages.append(
+ ChatMessage(
+ role=message.role,
+ text=text.strip(),
+ author_name=message.author_name,
+ message_id=message.message_id,
+ additional_properties=message.additional_properties,
+ raw_representation=message.raw_representation,
+ )
+ )
+
+ if not found_prefix:
+ raise RuntimeError("Expected chat middleware prefix not found in agent response.")
+ if not found_validation:
+ raise RuntimeError("Expected validation note not found in agent response.")
+
+ cleaned_messages.append(ChatMessage(role=Role.ASSISTANT, text=" Agent: OK"))
+ response.messages = cleaned_messages
+ return response
+
+ if context.stream and isinstance(context.result, ResponseStream):
+
+ def _clean_update(update: AgentResponseUpdate) -> AgentResponseUpdate:
+ for content in update.contents or []:
+ if not content.text:
+ continue
+ text = content.text
+ if "Weather Advisory:" in text:
+ state["found_prefix"] = True
+ text = text.replace("Weather Advisory:", "")
+ text = re.sub(r"\[\d+\]\s*", "", text)
+ content.text = text
+ return update
+
+ context.result.with_update_hook(_clean_update)
+ context.result.with_finalizer(_sanitize)
+ elif isinstance(context.result, AgentResponse):
+ context.result = _sanitize(context.result)
async def main() -> None:
"""Example demonstrating result override with middleware for both streaming and non-streaming."""
- print("=== Result Override Middleware Example ===")
+ print("=== Result Override MiddlewareTypes Example ===")
# For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred
# authentication option.
- async with (
- AzureCliCredential() as credential,
- AzureAIAgentClient(credential=credential).as_agent(
- name="WeatherAgent",
- instructions="You are a helpful weather assistant. Use the weather tool to get current conditions.",
- tools=get_weather,
- middleware=[weather_override_middleware],
- ) as agent,
- ):
- # Non-streaming example
- print("\n--- Non-streaming Example ---")
- query = "What's the weather like in Seattle?"
- print(f"User: {query}")
- result = await agent.run(query)
- print(f"Agent: {result}")
-
- # Streaming example
- print("\n--- Streaming Example ---")
- query = "What's the weather like in Portland?"
- print(f"User: {query}")
- print("Agent: ", end="", flush=True)
- async for chunk in agent.run_stream(query):
- if chunk.text:
- print(chunk.text, end="", flush=True)
+ agent = OpenAIResponsesClient(
+ middleware=[validate_weather_middleware, weather_override_middleware],
+ ).as_agent(
+ name="WeatherAgent",
+ instructions="You are a helpful weather assistant. Use the weather tool to get current conditions.",
+ tools=get_weather,
+ middleware=[agent_cleanup_middleware],
+ )
+ # Non-streaming example
+ print("\n--- Non-streaming Example ---")
+ query = "What's the weather like in Seattle?"
+ print(f"User: {query}")
+ result = await agent.run(query)
+ print(f"Agent: {result}")
+
+ # Streaming example
+ print("\n--- Streaming Example ---")
+ query = "What's the weather like in Portland?"
+ print(f"User: {query}")
+ print("Agent: ", end="", flush=True)
+ response = agent.run(query, stream=True)
+ async for chunk in response:
+ if chunk.text:
+ print(chunk.text, end="", flush=True)
+ print("\n")
+ print(f"Final Result: {(await response.get_final_response()).text}")
if __name__ == "__main__":
diff --git a/python/samples/getting_started/middleware/runtime_context_delegation.py b/python/samples/getting_started/middleware/runtime_context_delegation.py
index 44ee2a7893..d4669239a6 100644
--- a/python/samples/getting_started/middleware/runtime_context_delegation.py
+++ b/python/samples/getting_started/middleware/runtime_context_delegation.py
@@ -16,9 +16,9 @@
Patterns Demonstrated:
-1. **Pattern 1: Single Agent with Middleware & Closure** (Lines 130-180)
+1. **Pattern 1: Single Agent with MiddlewareTypes & Closure** (Lines 130-180)
- Best for: Single agent with multiple tools
- - How: Middleware stores kwargs in container, tools access via closure
+ - How: MiddlewareTypes stores kwargs in container, tools access via closure
- Pros: Simple, explicit state management
- Cons: Requires container instance per agent
@@ -28,7 +28,7 @@
- Pros: Automatic, works with nested delegation, clean separation
- Cons: None - this is the recommended pattern for hierarchical agents
-3. **Pattern 3: Mixed - Hierarchical with Middleware** (Lines 250-300)
+3. **Pattern 3: Mixed - Hierarchical with MiddlewareTypes** (Lines 250-300)
- Best for: Complex scenarios needing both delegation and state management
- How: Combines automatic kwargs propagation with middleware processing
- Pros: Maximum flexibility, can transform/validate context at each level
@@ -36,7 +36,7 @@
Key Concepts:
- Runtime Context: Session-specific data like API tokens, user IDs, tenant info
-- Middleware: Intercepts function calls to access/modify kwargs
+- MiddlewareTypes: Intercepts function calls to access/modify kwargs
- Closure: Functions capturing variables from outer scope
- kwargs Propagation: Automatic forwarding of runtime context through delegation chains
"""
@@ -56,7 +56,7 @@ async def inject_context_middleware(
context: FunctionInvocationContext,
next: Callable[[FunctionInvocationContext], Awaitable[None]],
) -> None:
- """Middleware that extracts runtime context from kwargs and stores in container.
+ """MiddlewareTypes that extracts runtime context from kwargs and stores in container.
This middleware runs before tool execution and makes runtime context
available to tools via the container instance.
@@ -68,7 +68,7 @@ async def inject_context_middleware(
# Log what we captured (for demonstration)
if self.api_token or self.user_id:
- print("[Middleware] Captured runtime context:")
+ print("[MiddlewareTypes] Captured runtime context:")
print(f" - API Token: {'[PRESENT]' if self.api_token else '[NOT PROVIDED]'}")
print(f" - User ID: {'[PRESENT]' if self.user_id else '[NOT PROVIDED]'}")
print(f" - Session Metadata Keys: {list(self.session_metadata.keys())}")
@@ -140,7 +140,7 @@ async def send_notification(
async def pattern_1_single_agent_with_closure() -> None:
"""Pattern 1: Single agent with middleware and closure for runtime context."""
print("\n" + "=" * 70)
- print("PATTERN 1: Single Agent with Middleware & Closure")
+ print("PATTERN 1: Single Agent with MiddlewareTypes & Closure")
print("=" * 70)
print("Use case: Single agent with multiple tools sharing runtime context")
print()
@@ -234,7 +234,7 @@ async def pattern_1_single_agent_with_closure() -> None:
print(f"\nAgent: {result4.text}")
- print("\n✓ Pattern 1 complete - Middleware & closure pattern works for single agents")
+ print("\n✓ Pattern 1 complete - MiddlewareTypes & closure pattern works for single agents")
# Pattern 2: Hierarchical agents with automatic kwargs propagation
@@ -353,7 +353,7 @@ async def sms_kwargs_tracker(
class AuthContextMiddleware:
- """Middleware that validates and transforms runtime context."""
+ """MiddlewareTypes that validates and transforms runtime context."""
def __init__(self) -> None:
self.validated_tokens: list[str] = []
@@ -387,7 +387,7 @@ async def protected_operation(operation: Annotated[str, Field(description="Opera
async def pattern_3_hierarchical_with_middleware() -> None:
"""Pattern 3: Hierarchical agents with middleware processing at each level."""
print("\n" + "=" * 70)
- print("PATTERN 3: Hierarchical with Middleware Processing")
+ print("PATTERN 3: Hierarchical with MiddlewareTypes Processing")
print("=" * 70)
print("Use case: Multi-level validation/transformation of runtime context")
print()
@@ -433,7 +433,7 @@ async def pattern_3_hierarchical_with_middleware() -> None:
)
print(f"\n[Validation Summary] Validated tokens: {len(auth_middleware.validated_tokens)}")
- print("✓ Pattern 3 complete - Middleware can validate/transform context at each level")
+ print("✓ Pattern 3 complete - MiddlewareTypes can validate/transform context at each level")
async def main() -> None:
diff --git a/python/samples/getting_started/middleware/shared_state_middleware.py b/python/samples/getting_started/middleware/shared_state_middleware.py
index f2a5232262..f48ec3807d 100644
--- a/python/samples/getting_started/middleware/shared_state_middleware.py
+++ b/python/samples/getting_started/middleware/shared_state_middleware.py
@@ -14,7 +14,7 @@
from pydantic import Field
"""
-Shared State Function-based Middleware Example
+Shared State Function-based MiddlewareTypes Example
This sample demonstrates how to implement function-based middleware within a class to share state.
The example includes:
@@ -88,7 +88,7 @@ async def result_enhancer_middleware(
async def main() -> None:
"""Example demonstrating shared state function-based middleware."""
- print("=== Shared State Function-based Middleware Example ===")
+ print("=== Shared State Function-based MiddlewareTypes Example ===")
# Create middleware container with shared state
middleware_container = MiddlewareContainer()
diff --git a/python/samples/getting_started/middleware/thread_behavior_middleware.py b/python/samples/getting_started/middleware/thread_behavior_middleware.py
index 5cca8cb635..93f72d567a 100644
--- a/python/samples/getting_started/middleware/thread_behavior_middleware.py
+++ b/python/samples/getting_started/middleware/thread_behavior_middleware.py
@@ -14,7 +14,7 @@
from pydantic import Field
"""
-Thread Behavior Middleware Example
+Thread Behavior MiddlewareTypes Example
This sample demonstrates how middleware can access and track thread state across multiple agent runs.
The example shows:
@@ -48,13 +48,13 @@ async def thread_tracking_middleware(
context: AgentRunContext,
next: Callable[[AgentRunContext], Awaitable[None]],
) -> None:
- """Middleware that tracks and logs thread behavior across runs."""
+ """MiddlewareTypes that tracks and logs thread behavior across runs."""
thread_messages = []
if context.thread and context.thread.message_store:
thread_messages = await context.thread.message_store.list_messages()
- print(f"[Middleware pre-execution] Current input messages: {len(context.messages)}")
- print(f"[Middleware pre-execution] Thread history messages: {len(thread_messages)}")
+ print(f"[MiddlewareTypes pre-execution] Current input messages: {len(context.messages)}")
+ print(f"[MiddlewareTypes pre-execution] Thread history messages: {len(thread_messages)}")
# Call next to execute the agent
await next(context)
@@ -64,12 +64,12 @@ async def thread_tracking_middleware(
if context.thread and context.thread.message_store:
updated_thread_messages = await context.thread.message_store.list_messages()
- print(f"[Middleware post-execution] Updated thread messages: {len(updated_thread_messages)}")
+ print(f"[MiddlewareTypes post-execution] Updated thread messages: {len(updated_thread_messages)}")
async def main() -> None:
"""Example demonstrating thread behavior in middleware across multiple runs."""
- print("=== Thread Behavior Middleware Example ===")
+ print("=== Thread Behavior MiddlewareTypes Example ===")
# For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred
# authentication option.
diff --git a/python/samples/getting_started/observability/advanced_manual_setup_console_output.py b/python/samples/getting_started/observability/advanced_manual_setup_console_output.py
index 1ac8fae8da..0b6a908b0d 100644
--- a/python/samples/getting_started/observability/advanced_manual_setup_console_output.py
+++ b/python/samples/getting_started/observability/advanced_manual_setup_console_output.py
@@ -107,7 +107,7 @@ async def run_chat_client() -> None:
message = "What's the weather in Amsterdam and in Paris?"
print(f"User: {message}")
print("Assistant: ", end="")
- async for chunk in client.get_streaming_response(message, tools=get_weather):
+ async for chunk in client.get_response(message, tools=get_weather, stream=True):
if str(chunk):
print(str(chunk), end="")
print("")
diff --git a/python/samples/getting_started/observability/advanced_zero_code.py b/python/samples/getting_started/observability/advanced_zero_code.py
index 5f60af0327..5ac0c70c22 100644
--- a/python/samples/getting_started/observability/advanced_zero_code.py
+++ b/python/samples/getting_started/observability/advanced_zero_code.py
@@ -81,7 +81,7 @@ async def run_chat_client(client: "ChatClientProtocol", stream: bool = False) ->
print(f"User: {message}")
if stream:
print("Assistant: ", end="")
- async for chunk in client.get_streaming_response(message, tools=get_weather):
+ async for chunk in client.get_response(message, tools=get_weather, stream=True):
if str(chunk):
print(str(chunk), end="")
print("")
diff --git a/python/samples/getting_started/observability/agent_observability.py b/python/samples/getting_started/observability/agent_observability.py
index 1c5828d56e..278b508de6 100644
--- a/python/samples/getting_started/observability/agent_observability.py
+++ b/python/samples/getting_started/observability/agent_observability.py
@@ -50,9 +50,10 @@ async def main():
for question in questions:
print(f"\nUser: {question}")
print(f"{agent.name}: ", end="")
- async for update in agent.run_stream(
+ async for update in agent.run(
question,
thread=thread,
+ stream=True,
):
if update.text:
print(update.text, end="")
diff --git a/python/samples/getting_started/observability/agent_with_foundry_tracing.py b/python/samples/getting_started/observability/agent_with_foundry_tracing.py
index 72fd74facf..0e84a171fa 100644
--- a/python/samples/getting_started/observability/agent_with_foundry_tracing.py
+++ b/python/samples/getting_started/observability/agent_with_foundry_tracing.py
@@ -87,10 +87,7 @@ async def main():
for question in questions:
print(f"\nUser: {question}")
print(f"{agent.name}: ", end="")
- async for update in agent.run_stream(
- question,
- thread=thread,
- ):
+ async for update in agent.run(question, thread=thread, stream=True):
if update.text:
print(update.text, end="")
diff --git a/python/samples/getting_started/observability/azure_ai_agent_observability.py b/python/samples/getting_started/observability/azure_ai_agent_observability.py
index 56aa228386..08ac327913 100644
--- a/python/samples/getting_started/observability/azure_ai_agent_observability.py
+++ b/python/samples/getting_started/observability/azure_ai_agent_observability.py
@@ -67,10 +67,7 @@ async def main():
for question in questions:
print(f"\nUser: {question}")
print(f"{agent.name}: ", end="")
- async for update in agent.run_stream(
- question,
- thread=thread,
- ):
+ async for update in agent.run(question, thread=thread, stream=True):
if update.text:
print(update.text, end="")
diff --git a/python/samples/getting_started/observability/configure_otel_providers_with_env_var.py b/python/samples/getting_started/observability/configure_otel_providers_with_env_var.py
index f900b8cf6e..014f387033 100644
--- a/python/samples/getting_started/observability/configure_otel_providers_with_env_var.py
+++ b/python/samples/getting_started/observability/configure_otel_providers_with_env_var.py
@@ -71,7 +71,7 @@ async def run_chat_client(client: "ChatClientProtocol", stream: bool = False) ->
print(f"User: {message}")
if stream:
print("Assistant: ", end="")
- async for chunk in client.get_streaming_response(message, tools=get_weather):
+ async for chunk in client.get_response(message, tools=get_weather, stream=True):
if str(chunk):
print(str(chunk), end="")
print("")
diff --git a/python/samples/getting_started/observability/configure_otel_providers_with_parameters.py b/python/samples/getting_started/observability/configure_otel_providers_with_parameters.py
index 0929114a60..a5b0b3d7a8 100644
--- a/python/samples/getting_started/observability/configure_otel_providers_with_parameters.py
+++ b/python/samples/getting_started/observability/configure_otel_providers_with_parameters.py
@@ -71,7 +71,7 @@ async def run_chat_client(client: "ChatClientProtocol", stream: bool = False) ->
print(f"User: {message}")
if stream:
print("Assistant: ", end="")
- async for chunk in client.get_streaming_response(message, tools=get_weather):
+ async for chunk in client.get_response(message, stream=True, tools=get_weather):
if str(chunk):
print(str(chunk), end="")
print("")
diff --git a/python/samples/getting_started/observability/workflow_observability.py b/python/samples/getting_started/observability/workflow_observability.py
index 7cd5174025..96a3565476 100644
--- a/python/samples/getting_started/observability/workflow_observability.py
+++ b/python/samples/getting_started/observability/workflow_observability.py
@@ -92,7 +92,7 @@ async def run_sequential_workflow() -> None:
print(f"Starting workflow with input: '{input_text}'")
output_event = None
- async for event in workflow.run_stream("Hello world"):
+ async for event in workflow.run("Hello world", stream=True):
if isinstance(event, WorkflowOutputEvent):
# The WorkflowOutputEvent contains the final result.
output_event = event
diff --git a/python/samples/getting_started/orchestrations/group_chat_agent_manager.py b/python/samples/getting_started/orchestrations/group_chat_agent_manager.py
index 940bb14c66..f9e7a072a1 100644
--- a/python/samples/getting_started/orchestrations/group_chat_agent_manager.py
+++ b/python/samples/getting_started/orchestrations/group_chat_agent_manager.py
@@ -87,7 +87,7 @@ async def main() -> None:
# Keep track of the last response to format output nicely in streaming mode
last_response_id: str | None = None
- async for event in workflow.run_stream(task):
+ async for event in workflow.run(task, stream=True):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, AgentResponseUpdate):
diff --git a/python/samples/getting_started/orchestrations/group_chat_philosophical_debate.py b/python/samples/getting_started/orchestrations/group_chat_philosophical_debate.py
index 6f817f5eef..70154d07f4 100644
--- a/python/samples/getting_started/orchestrations/group_chat_philosophical_debate.py
+++ b/python/samples/getting_started/orchestrations/group_chat_philosophical_debate.py
@@ -240,7 +240,7 @@ async def main() -> None:
# Keep track of the last response to format output nicely in streaming mode
last_response_id: str | None = None
- async for event in workflow.run_stream(f"Please begin the discussion on: {topic}"):
+ async for event in workflow.run(f"Please begin the discussion on: {topic}", stream=True):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, AgentResponseUpdate):
diff --git a/python/samples/getting_started/orchestrations/group_chat_simple_selector.py b/python/samples/getting_started/orchestrations/group_chat_simple_selector.py
index 012a31c72d..f2e5560128 100644
--- a/python/samples/getting_started/orchestrations/group_chat_simple_selector.py
+++ b/python/samples/getting_started/orchestrations/group_chat_simple_selector.py
@@ -105,7 +105,7 @@ async def main() -> None:
# Keep track of the last response to format output nicely in streaming mode
last_response_id: str | None = None
- async for event in workflow.run_stream(task):
+ async for event in workflow.run(task, stream=True):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, AgentResponseUpdate):
diff --git a/python/samples/getting_started/orchestrations/handoff_autonomous.py b/python/samples/getting_started/orchestrations/handoff_autonomous.py
index 277bf1abd0..76a5c7cfd2 100644
--- a/python/samples/getting_started/orchestrations/handoff_autonomous.py
+++ b/python/samples/getting_started/orchestrations/handoff_autonomous.py
@@ -111,7 +111,7 @@ async def main() -> None:
print("Request:", request)
last_response_id: str | None = None
- async for event in workflow.run_stream(request):
+ async for event in workflow.run(request, stream=True):
if isinstance(event, HandoffSentEvent):
print(f"\nHandoff Event: from {event.source} to {event.target}\n")
elif isinstance(event, WorkflowOutputEvent):
diff --git a/python/samples/getting_started/orchestrations/handoff_simple.py b/python/samples/getting_started/orchestrations/handoff_simple.py
index 9db5a38590..d439d5a719 100644
--- a/python/samples/getting_started/orchestrations/handoff_simple.py
+++ b/python/samples/getting_started/orchestrations/handoff_simple.py
@@ -233,12 +233,12 @@ async def main() -> None:
]
# Start the workflow with the initial user message
- # run_stream() returns an async iterator of WorkflowEvent
+ # run(..., stream=True) returns an async iterator of WorkflowEvent
print("[Starting workflow with initial user message...]\n")
initial_message = "Hello, I need assistance with my recent purchase."
print(f"- User: {initial_message}")
- workflow_result = await workflow.run(initial_message)
- pending_requests = _handle_events(workflow_result)
+ workflow_result = workflow.run(initial_message, stream=True)
+ pending_requests = _handle_events([event async for event in workflow_result])
# Process the request/response cycle
# The workflow will continue requesting input until:
diff --git a/python/samples/getting_started/orchestrations/handoff_with_code_interpreter_file.py b/python/samples/getting_started/orchestrations/handoff_with_code_interpreter_file.py
index aa4025f9bf..d6b335e15c 100644
--- a/python/samples/getting_started/orchestrations/handoff_with_code_interpreter_file.py
+++ b/python/samples/getting_started/orchestrations/handoff_with_code_interpreter_file.py
@@ -187,7 +187,7 @@ async def main() -> None:
all_file_ids: list[str] = []
print(f"User: {user_inputs[0]}")
- events = await _drain(workflow.run_stream(user_inputs[0]))
+ events = await _drain(workflow.run(user_inputs[0], stream=True))
requests, file_ids = _handle_events(events)
all_file_ids.extend(file_ids)
input_index += 1
diff --git a/python/samples/getting_started/orchestrations/magentic.py b/python/samples/getting_started/orchestrations/magentic.py
index 0e5b73e104..ae426685d9 100644
--- a/python/samples/getting_started/orchestrations/magentic.py
+++ b/python/samples/getting_started/orchestrations/magentic.py
@@ -104,7 +104,7 @@ async def main() -> None:
# Keep track of the last executor to format output nicely in streaming mode
last_response_id: str | None = None
- async for event in workflow.run_stream(task):
+ async for event in workflow.run(task, stream=True):
if isinstance(event, MagenticOrchestratorEvent):
print(f"\n[Magentic Orchestrator Event] Type: {event.event_type.name}")
if isinstance(event.data, ChatMessage):
diff --git a/python/samples/getting_started/orchestrations/magentic_checkpoint.py b/python/samples/getting_started/orchestrations/magentic_checkpoint.py
index 48f9dce5be..08b233661b 100644
--- a/python/samples/getting_started/orchestrations/magentic_checkpoint.py
+++ b/python/samples/getting_started/orchestrations/magentic_checkpoint.py
@@ -109,7 +109,7 @@ async def main() -> None:
# request_id we must reuse on resume. In a real system this is where the UI would present
# the plan for human review.
plan_review_request: MagenticPlanReviewRequest | None = None
- async for event in workflow.run_stream(TASK):
+ async for event in workflow.run(TASK, stream=True):
if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
plan_review_request = event.data
print(f"Captured plan review request: {event.request_id}")
@@ -148,7 +148,7 @@ async def main() -> None:
# Resume execution and capture the re-emitted plan review request.
request_info_event: RequestInfoEvent | None = None
- async for event in resumed_workflow.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id):
+ async for event in resumed_workflow.run(checkpoint_id=resume_checkpoint.checkpoint_id, stream=True):
if isinstance(event, RequestInfoEvent) and isinstance(event.data, MagenticPlanReviewRequest):
request_info_event = event
@@ -221,7 +221,7 @@ def _pending_message_count(cp: WorkflowCheckpoint) -> int:
final_event_post: WorkflowOutputEvent | None = None
post_emitted_events = False
post_plan_workflow = build_workflow(checkpoint_storage)
- async for event in post_plan_workflow.run_stream(checkpoint_id=post_plan_checkpoint.checkpoint_id):
+ async for event in post_plan_workflow.run(checkpoint_id=post_plan_checkpoint.checkpoint_id, stream=True):
post_emitted_events = True
if isinstance(event, WorkflowOutputEvent):
final_event_post = event
diff --git a/python/samples/getting_started/orchestrations/magentic_human_plan_review.py b/python/samples/getting_started/orchestrations/magentic_human_plan_review.py
index 2413a4c47e..9af07ae13f 100644
--- a/python/samples/getting_started/orchestrations/magentic_human_plan_review.py
+++ b/python/samples/getting_started/orchestrations/magentic_human_plan_review.py
@@ -142,7 +142,7 @@ async def main() -> None:
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run or send_responses_streaming.
- stream = workflow.run_stream(task)
+ stream = workflow.run(task, stream=True)
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
diff --git a/python/samples/getting_started/orchestrations/sequential_agents.py b/python/samples/getting_started/orchestrations/sequential_agents.py
index 681a810846..b0cea780a7 100644
--- a/python/samples/getting_started/orchestrations/sequential_agents.py
+++ b/python/samples/getting_started/orchestrations/sequential_agents.py
@@ -47,7 +47,7 @@ async def main() -> None:
# 3) Run and collect outputs
outputs: list[list[ChatMessage]] = []
- async for event in workflow.run_stream("Write a tagline for a budget-friendly eBike."):
+ async for event in workflow.run("Write a tagline for a budget-friendly eBike.", stream=True):
if isinstance(event, WorkflowOutputEvent):
outputs.append(cast(list[ChatMessage], event.data))
diff --git a/python/samples/getting_started/purview_agent/sample_purview_agent.py b/python/samples/getting_started/purview_agent/sample_purview_agent.py
index cb79042979..b5231c2a5f 100644
--- a/python/samples/getting_started/purview_agent/sample_purview_agent.py
+++ b/python/samples/getting_started/purview_agent/sample_purview_agent.py
@@ -157,7 +157,7 @@ async def run_with_agent_middleware() -> None:
middleware=[purview_agent_middleware],
)
- print("-- Agent Middleware Path --")
+ print("-- Agent MiddlewareTypes Path --")
first: AgentResponse = await agent.run(
ChatMessage("user", ["Tell me a joke about a pirate."], additional_properties={"user_id": user_id})
)
@@ -200,7 +200,7 @@ async def run_with_chat_middleware() -> None:
name=JOKER_NAME,
)
- print("-- Chat Middleware Path --")
+ print("-- Chat MiddlewareTypes Path --")
first: AgentResponse = await agent.run(
ChatMessage(
role="user",
@@ -305,7 +305,7 @@ async def run_with_custom_cache_provider() -> None:
async def main() -> None:
- print("== Purview Agent Sample (Middleware with Automatic Caching) ==")
+ print("== Purview Agent Sample (MiddlewareTypes with Automatic Caching) ==")
try:
await run_with_agent_middleware()
diff --git a/python/samples/getting_started/tools/function_tool_with_approval.py b/python/samples/getting_started/tools/function_tool_with_approval.py
index 188697a8ce..d740f8bad0 100644
--- a/python/samples/getting_started/tools/function_tool_with_approval.py
+++ b/python/samples/getting_started/tools/function_tool_with_approval.py
@@ -88,7 +88,7 @@ async def handle_approvals_streaming(query: str, agent: "AgentProtocol") -> None
user_input_requests: list[Any] = []
# Stream the response
- async for chunk in agent.run_stream(current_input):
+ async for chunk in agent.run(current_input, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
@@ -123,9 +123,9 @@ async def handle_approvals_streaming(query: str, agent: "AgentProtocol") -> None
current_input = new_inputs
-async def run_weather_agent_with_approval(is_streaming: bool) -> None:
+async def run_weather_agent_with_approval(stream: bool) -> None:
"""Example showing AI function with approval requirement."""
- print(f"\n=== Weather Agent with Approval Required ({'Streaming' if is_streaming else 'Non-Streaming'}) ===\n")
+ print(f"\n=== Weather Agent with Approval Required ({'Streaming' if stream else 'Non-Streaming'}) ===\n")
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
@@ -136,7 +136,7 @@ async def run_weather_agent_with_approval(is_streaming: bool) -> None:
query = "Can you give me an update of the weather in LA and Portland and detailed weather for Seattle?"
print(f"User: {query}")
- if is_streaming:
+ if stream:
print(f"\n{agent.name}: ", end="", flush=True)
await handle_approvals_streaming(query, agent)
print()
@@ -148,8 +148,8 @@ async def run_weather_agent_with_approval(is_streaming: bool) -> None:
async def main() -> None:
print("=== Demonstration of a tool with approvals ===\n")
- await run_weather_agent_with_approval(is_streaming=False)
- await run_weather_agent_with_approval(is_streaming=True)
+ await run_weather_agent_with_approval(stream=False)
+ await run_weather_agent_with_approval(stream=True)
if __name__ == "__main__":
diff --git a/python/samples/getting_started/workflows/_start-here/step3_streaming.py b/python/samples/getting_started/workflows/_start-here/step3_streaming.py
index be7d2a3de6..2ac0f64ca8 100644
--- a/python/samples/getting_started/workflows/_start-here/step3_streaming.py
+++ b/python/samples/getting_started/workflows/_start-here/step3_streaming.py
@@ -52,8 +52,9 @@ async def main():
last_author: str | None = None
# Run the workflow with the user's initial message and stream events as they occur.
- async for event in workflow.run_stream(
- ChatMessage("user", ["Create a slogan for a new electric SUV that is affordable and fun to drive."])
+ async for event in workflow.run(
+ ChatMessage("user", ["Create a slogan for a new electric SUV that is affordable and fun to drive."]),
+ stream=True,
):
# The outputs of the workflow are whatever the agents produce. So the events are expected to
# contain `AgentResponseUpdate` from the agents in the workflow.
diff --git a/python/samples/getting_started/workflows/_start-here/step4_using_factories.py b/python/samples/getting_started/workflows/_start-here/step4_using_factories.py
index c39a198edc..d5e333ddbc 100644
--- a/python/samples/getting_started/workflows/_start-here/step4_using_factories.py
+++ b/python/samples/getting_started/workflows/_start-here/step4_using_factories.py
@@ -84,7 +84,7 @@ async def main():
)
first_update = True
- async for event in workflow.run_stream("hello world"):
+ async for event in workflow.run("hello world", stream=True):
# The outputs of the workflow are whatever the agents produce. So the events are expected to
# contain `AgentResponseUpdate` from the agents in the workflow.
if isinstance(event, WorkflowOutputEvent) and isinstance(event.data, AgentResponseUpdate):
diff --git a/python/samples/getting_started/workflows/agents/azure_ai_agents_streaming.py b/python/samples/getting_started/workflows/agents/azure_ai_agents_streaming.py
index 94386909e6..4b4ddbc38b 100644
--- a/python/samples/getting_started/workflows/agents/azure_ai_agents_streaming.py
+++ b/python/samples/getting_started/workflows/agents/azure_ai_agents_streaming.py
@@ -38,13 +38,15 @@ async def main() -> None:
)
# Build the workflow by adding agents directly as edges.
- # Agents adapt to workflow mode: run_stream() for incremental updates, run() for complete responses.
+ # Agents adapt to workflow mode: run(stream=True) for complete responses, run() for incremental updates.
workflow = WorkflowBuilder().set_start_executor(writer_agent).add_edge(writer_agent, reviewer_agent).build()
# Track the last author to format streaming output.
last_author: str | None = None
- events = workflow.run_stream("Create a slogan for a new electric SUV that is affordable and fun to drive.")
+ events = workflow.run(
+ "Create a slogan for a new electric SUV that is affordable and fun to drive.", stream=True
+ )
async for event in events:
# The outputs of the workflow are whatever the agents produce. So the events are expected to
# contain `AgentResponseUpdate` from the agents in the workflow.
diff --git a/python/samples/getting_started/workflows/agents/azure_chat_agents_and_executor.py b/python/samples/getting_started/workflows/agents/azure_chat_agents_and_executor.py
index d7c7b8c1d3..7d51660336 100644
--- a/python/samples/getting_started/workflows/agents/azure_chat_agents_and_executor.py
+++ b/python/samples/getting_started/workflows/agents/azure_chat_agents_and_executor.py
@@ -118,8 +118,8 @@ async def main() -> None:
.build()
)
- events = workflow.run_stream(
- "Create quick workspace wellness tips for a remote analyst working across two monitors."
+ events = workflow.run(
+ "Create quick workspace wellness tips for a remote analyst working across two monitors.", stream=True
)
# Track the last author to format streaming output.
diff --git a/python/samples/getting_started/workflows/agents/azure_chat_agents_streaming.py b/python/samples/getting_started/workflows/agents/azure_chat_agents_streaming.py
index ab1dc29ec1..627febb99a 100644
--- a/python/samples/getting_started/workflows/agents/azure_chat_agents_streaming.py
+++ b/python/samples/getting_started/workflows/agents/azure_chat_agents_streaming.py
@@ -39,13 +39,13 @@ async def main():
# Build the workflow using the fluent builder.
# Set the start node and connect an edge from writer to reviewer.
- # Agents adapt to workflow mode: run_stream() for incremental updates, run() for complete responses.
+ # Agents adapt to workflow mode: run(stream=True) for incremental updates, run() for complete responses.
workflow = WorkflowBuilder().set_start_executor(writer_agent).add_edge(writer_agent, reviewer_agent).build()
# Track the last author to format streaming output.
last_author: str | None = None
- events = workflow.run_stream("Create a slogan for a new electric SUV that is affordable and fun to drive.")
+ events = workflow.run("Create a slogan for a new electric SUV that is affordable and fun to drive.", stream=True)
async for event in events:
# The outputs of the workflow are whatever the agents produce. So the events are expected to
# contain `AgentResponseUpdate` from the agents in the workflow.
diff --git a/python/samples/getting_started/workflows/agents/azure_chat_agents_tool_calls_with_feedback.py b/python/samples/getting_started/workflows/agents/azure_chat_agents_tool_calls_with_feedback.py
new file mode 100644
index 0000000000..4b7eabf9ba
--- /dev/null
+++ b/python/samples/getting_started/workflows/agents/azure_chat_agents_tool_calls_with_feedback.py
@@ -0,0 +1,325 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import asyncio
+import json
+from dataclasses import dataclass, field
+from typing import Annotated
+
+from agent_framework import (
+ AgentExecutorRequest,
+ AgentExecutorResponse,
+ AgentResponse,
+ AgentRunUpdateEvent,
+ ChatAgent,
+ ChatMessage,
+ Executor,
+ FunctionCallContent,
+ FunctionResultContent,
+ RequestInfoEvent,
+ WorkflowBuilder,
+ WorkflowContext,
+ WorkflowOutputEvent,
+ handler,
+ response_handler,
+ tool,
+)
+from agent_framework.azure import AzureOpenAIChatClient
+from azure.identity import AzureCliCredential
+from pydantic import Field
+from typing_extensions import Never
+
+"""
+Sample: Tool-enabled agents with human feedback
+
+Pipeline layout:
+writer_agent (uses Azure OpenAI tools) -> Coordinator -> writer_agent
+-> Coordinator -> final_editor_agent -> Coordinator -> output
+
+The writer agent calls tools to gather product facts before drafting copy. A custom executor
+packages the draft and emits a RequestInfoEvent so a human can comment, then replays the human
+guidance back into the conversation before the final editor agent produces the polished output.
+
+Demonstrates:
+- Attaching Python function tools to an agent inside a workflow.
+- Capturing the writer's output for human review.
+- Streaming AgentRunUpdateEvent updates alongside human-in-the-loop pauses.
+
+Prerequisites:
+- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables.
+- Authentication via azure-identity. Run `az login` before executing.
+"""
+
+
+# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/getting_started/tools/function_tool_with_approval.py and samples/getting_started/tools/function_tool_with_approval_and_threads.py.
+@tool(approval_mode="never_require")
+def fetch_product_brief(
+ product_name: Annotated[str, Field(description="Product name to look up.")],
+) -> str:
+ """Return a marketing brief for a product."""
+ briefs = {
+ "lumenx desk lamp": (
+ "Product: LumenX Desk Lamp\n"
+ "- Three-point adjustable arm with 270° rotation.\n"
+ "- Custom warm-to-neutral LED spectrum (2700K-4000K).\n"
+ "- USB-C charging pad integrated in the base.\n"
+ "- Designed for home offices and late-night study sessions."
+ )
+ }
+ return briefs.get(product_name.lower(), f"No stored brief for '{product_name}'.")
+
+
+@tool(approval_mode="never_require")
+def get_brand_voice_profile(
+ voice_name: Annotated[str, Field(description="Brand or campaign voice to emulate.")],
+) -> str:
+ """Return guidance for the requested brand voice."""
+ voices = {
+ "lumenx launch": (
+ "Voice guidelines:\n"
+ "- Friendly and modern with concise sentences.\n"
+ "- Highlight practical benefits before aesthetics.\n"
+ "- End with an invitation to imagine the product in daily use."
+ )
+ }
+ return voices.get(voice_name.lower(), f"No stored voice profile for '{voice_name}'.")
+
+
+@dataclass
+class DraftFeedbackRequest:
+ """Payload sent for human review."""
+
+ prompt: str = ""
+ draft_text: str = ""
+ conversation: list[ChatMessage] = field(default_factory=list) # type: ignore[reportUnknownVariableType]
+
+
+class Coordinator(Executor):
+ """Bridge between the writer agent, human feedback, and final editor."""
+
+ def __init__(self, id: str, writer_id: str, final_editor_id: str) -> None:
+ super().__init__(id)
+ self.writer_id = writer_id
+ self.final_editor_id = final_editor_id
+
+ @handler
+ async def on_writer_response(
+ self,
+ draft: AgentExecutorResponse,
+ ctx: WorkflowContext[Never, AgentResponse],
+ ) -> None:
+ """Handle responses from the other two agents in the workflow."""
+ if draft.executor_id == self.final_editor_id:
+ # Final editor response; yield output directly.
+ await ctx.yield_output(draft.agent_response)
+ return
+
+ # Writer agent response; request human feedback.
+ # Preserve the full conversation so the final editor
+ # can see tool traces and the initial prompt.
+ conversation: list[ChatMessage]
+ if draft.full_conversation is not None:
+ conversation = list(draft.full_conversation)
+ else:
+ conversation = list(draft.agent_response.messages)
+ draft_text = draft.agent_response.text.strip()
+ if not draft_text:
+ draft_text = "No draft text was produced."
+
+ prompt = (
+ "Review the draft from the writer and provide a short directional note "
+ "(tone tweaks, must-have detail, target audience, etc.). "
+ "Keep it under 30 words."
+ )
+ await ctx.request_info(
+ request_data=DraftFeedbackRequest(prompt=prompt, draft_text=draft_text, conversation=conversation),
+ response_type=str,
+ )
+
+ @response_handler
+ async def on_human_feedback(
+ self,
+ original_request: DraftFeedbackRequest,
+ feedback: str,
+ ctx: WorkflowContext[AgentExecutorRequest],
+ ) -> None:
+ note = feedback.strip()
+ if note.lower() == "approve":
+ # Human approved the draft as-is; forward it unchanged.
+ await ctx.send_message(
+ AgentExecutorRequest(
+ messages=original_request.conversation
+ + [ChatMessage("user", text="The draft is approved as-is.")],
+ should_respond=True,
+ ),
+ target_id=self.final_editor_id,
+ )
+ return
+
+ # Human provided feedback; prompt the writer to revise.
+ conversation: list[ChatMessage] = list(original_request.conversation)
+ instruction = (
+ "A human reviewer shared the following guidance:\n"
+ f"{note or 'No specific guidance provided.'}\n\n"
+ "Rewrite the draft from the previous assistant message into a polished final version. "
+ "Keep the response under 120 words and reflect any requested tone adjustments."
+ )
+ conversation.append(ChatMessage("user", text=instruction))
+ await ctx.send_message(
+ AgentExecutorRequest(messages=conversation, should_respond=True), target_id=self.writer_id
+ )
+
+
+def create_writer_agent() -> ChatAgent:
+ """Creates a writer agent with tools."""
+ return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
+ name="writer_agent",
+ instructions=(
+ "You are a marketing writer. Call the available tools before drafting copy so you are precise. "
+ "Always call both tools once before drafting. Summarize tool outputs as bullet points, then "
+ "produce a 3-sentence draft."
+ ),
+ tools=[fetch_product_brief, get_brand_voice_profile],
+ tool_choice="required",
+ )
+
+
+def create_final_editor_agent() -> ChatAgent:
+ """Creates a final editor agent."""
+ return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
+ name="final_editor_agent",
+ instructions=(
+ "You are an editor who polishes marketing copy after human approval. "
+ "Correct any legal or factual issues. Return the final version even if no changes are made. "
+ ),
+ )
+
+
+def display_agent_run_update(event: AgentRunUpdateEvent, last_executor: str | None) -> None:
+ """Display an AgentRunUpdateEvent in a readable format."""
+ printed_tool_calls: set[str] = set()
+ printed_tool_results: set[str] = set()
+ executor_id = event.executor_id
+ update = event.data
+ # Extract and print any new tool calls or results from the update.
+ function_calls = [c for c in update.contents if isinstance(c, FunctionCallContent)] # type: ignore[union-attr]
+ function_results = [c for c in update.contents if isinstance(c, FunctionResultContent)] # type: ignore[union-attr]
+ if executor_id != last_executor:
+ if last_executor is not None:
+ print()
+ print(f"{executor_id}:", end=" ", flush=True)
+ last_executor = executor_id
+ # Print any new tool calls before the text update.
+ for call in function_calls:
+ if call.call_id in printed_tool_calls:
+ continue
+ printed_tool_calls.add(call.call_id)
+ args = call.arguments
+ args_preview = json.dumps(args, ensure_ascii=False) if isinstance(args, dict) else (args or "").strip()
+ print(
+ f"\n{executor_id} [tool-call] {call.name}({args_preview})",
+ flush=True,
+ )
+ print(f"{executor_id}:", end=" ", flush=True)
+ # Print any new tool results before the text update.
+ for result in function_results:
+ if result.call_id in printed_tool_results:
+ continue
+ printed_tool_results.add(result.call_id)
+ result_text = result.result
+ if not isinstance(result_text, str):
+ result_text = json.dumps(result_text, ensure_ascii=False)
+ print(
+ f"\n{executor_id} [tool-result] {result.call_id}: {result_text}",
+ flush=True,
+ )
+ print(f"{executor_id}:", end=" ", flush=True)
+ # Finally, print the text update.
+ print(update, end="", flush=True)
+
+
+async def main() -> None:
+ """Run the workflow and bridge human feedback between two agents."""
+
+ # Build the workflow.
+ workflow = (
+ WorkflowBuilder()
+ .register_agent(create_writer_agent, name="writer_agent")
+ .register_agent(create_final_editor_agent, name="final_editor_agent")
+ .register_executor(
+ lambda: Coordinator(
+ id="coordinator",
+ writer_id="writer_agent",
+ final_editor_id="final_editor_agent",
+ ),
+ name="coordinator",
+ )
+ .set_start_executor("writer_agent")
+ .add_edge("writer_agent", "coordinator")
+ .add_edge("coordinator", "writer_agent")
+ .add_edge("final_editor_agent", "coordinator")
+ .add_edge("coordinator", "final_editor_agent")
+ .build()
+ )
+
+ # Switch to turn on agent run update display.
+ # By default this is off to reduce clutter during human input.
+ display_agent_run_update_switch = False
+
+ print(
+ "Interactive mode. When prompted, provide a short feedback note for the editor.",
+ flush=True,
+ )
+
+ pending_responses: dict[str, str] | None = None
+ completed = False
+ initial_run = True
+
+ while not completed:
+ last_executor: str | None = None
+ if initial_run:
+ stream = workflow.run(
+ "Create a short launch blurb for the LumenX desk lamp. Emphasize adjustability and warm lighting.",
+ stream=True,
+ )
+ initial_run = False
+ elif pending_responses is not None:
+ stream = workflow.send_responses_streaming(pending_responses)
+ pending_responses = None
+ else:
+ break
+
+ requests: list[tuple[str, DraftFeedbackRequest]] = []
+
+ async for event in stream:
+ if isinstance(event, AgentRunUpdateEvent) and display_agent_run_update_switch:
+ display_agent_run_update(event, last_executor)
+ if isinstance(event, RequestInfoEvent) and isinstance(event.data, DraftFeedbackRequest):
+ # Stash the request so we can prompt the human after the stream completes.
+ requests.append((event.request_id, event.data))
+ last_executor = None
+ elif isinstance(event, WorkflowOutputEvent):
+ last_executor = None
+ response = event.data
+ print("\n===== Final output =====")
+ final_text = getattr(response, "text", str(response))
+ print(final_text.strip())
+ completed = True
+
+ if requests and not completed:
+ responses: dict[str, str] = {}
+ for request_id, request in requests:
+ print("\n----- Writer draft -----")
+ print(request.draft_text.strip())
+ print("\nProvide guidance for the editor (or 'approve' to accept the draft).")
+ answer = input("Human feedback: ").strip() # noqa: ASYNC250
+ if answer.lower() == "exit":
+ print("Exiting...")
+ return
+ responses[request_id] = answer
+ pending_responses = responses
+
+ print("Workflow complete.")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/python/samples/getting_started/workflows/agents/magentic_workflow_as_agent.py b/python/samples/getting_started/workflows/agents/magentic_workflow_as_agent.py
index 4e5b700e66..c0d51777f3 100644
--- a/python/samples/getting_started/workflows/agents/magentic_workflow_as_agent.py
+++ b/python/samples/getting_started/workflows/agents/magentic_workflow_as_agent.py
@@ -85,7 +85,7 @@ async def main() -> None:
workflow_agent = workflow.as_agent(name="MagenticWorkflowAgent")
last_response_id: str | None = None
- async for update in workflow_agent.run_stream(task):
+ async for update in workflow_agent.run(task, stream=True):
# Fallback for any other events with text
if last_response_id != update.response_id:
if last_response_id is not None:
diff --git a/python/samples/getting_started/workflows/agents/workflow_as_agent_kwargs.py b/python/samples/getting_started/workflows/agents/workflow_as_agent_kwargs.py
index 305f6ae07b..1fee49fc1d 100644
--- a/python/samples/getting_started/workflows/agents/workflow_as_agent_kwargs.py
+++ b/python/samples/getting_started/workflows/agents/workflow_as_agent_kwargs.py
@@ -4,8 +4,9 @@
import json
from typing import Annotated, Any
-from agent_framework import SequentialBuilder, tool
+from agent_framework import tool
from agent_framework.openai import OpenAIChatClient
+from agent_framework.orchestrations import SequentialBuilder
from pydantic import Field
"""
@@ -17,7 +18,7 @@
Key Concepts:
- Build a workflow using SequentialBuilder (or any builder pattern)
- Expose the workflow as a reusable agent via workflow.as_agent()
-- Pass custom context as kwargs when invoking workflow_agent.run() or run_stream()
+- Pass custom context as kwargs when invoking workflow_agent.run()
- kwargs are stored in State and propagated to all agent invocations
- @tool functions receive kwargs via **kwargs parameter
@@ -121,12 +122,12 @@ async def main() -> None:
print("-" * 70)
# Run workflow agent with kwargs - these will flow through to tools
- # Note: kwargs are passed to workflow_agent.run_stream() just like workflow.run_stream()
+ # Note: kwargs are passed to workflow.run()
print("\n===== Streaming Response =====")
- async for update in workflow_agent.run_stream(
+ async for update in workflow_agent.run(
"Please get my user data and then call the users API endpoint.",
- custom_data=custom_data,
- user_token=user_token,
+ additional_function_arguments={"custom_data": custom_data, "user_token": user_token},
+ stream=True,
):
if update.text:
print(update.text, end="", flush=True)
diff --git a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py
index da99031b2e..1f7f5659af 100644
--- a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py
+++ b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py
@@ -251,10 +251,10 @@ async def run_interactive_session(
else:
if initial_message:
print(f"\nStarting workflow with brief: {initial_message}\n")
- event_stream = workflow.run_stream(message=initial_message)
+ event_stream = workflow.run(message=initial_message, stream=True)
elif checkpoint_id:
print("\nStarting workflow from checkpoint...\n")
- event_stream = workflow.run_stream(checkpoint_id=checkpoint_id)
+ event_stream = workflow.run(checkpoint_id=checkpoint_id, stream=True)
else:
raise ValueError("Either initial_message or checkpoint_id must be provided")
diff --git a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py
index a6f0a2431b..b82eaf80e9 100644
--- a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py
+++ b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py
@@ -119,9 +119,9 @@ async def main():
# Start from checkpoint or fresh execution
print(f"\n** Workflow {workflow.id} started **")
event_stream = (
- workflow.run_stream(message=10)
+ workflow.run(message=10, stream=True)
if latest_checkpoint is None
- else workflow.run_stream(checkpoint_id=latest_checkpoint.checkpoint_id)
+ else workflow.run(checkpoint_id=latest_checkpoint.checkpoint_id, stream=True)
)
output: str | None = None
diff --git a/python/samples/getting_started/workflows/checkpoint/handoff_with_tool_approval_checkpoint_resume.py b/python/samples/getting_started/workflows/checkpoint/handoff_with_tool_approval_checkpoint_resume.py
index dbc51263d8..5ab80e37ee 100644
--- a/python/samples/getting_started/workflows/checkpoint/handoff_with_tool_approval_checkpoint_resume.py
+++ b/python/samples/getting_started/workflows/checkpoint/handoff_with_tool_approval_checkpoint_resume.py
@@ -39,7 +39,7 @@
6. Workflow continues from the saved state.
Pattern:
-- Step 1: workflow.run_stream(checkpoint_id=...) to restore checkpoint and pending requests.
+- Step 1: workflow.run(checkpoint_id=..., stream=True) to restore checkpoint and pending requests.
- Step 2: workflow.send_responses_streaming(responses) to supply human replies and approvals.
- Two-step approach is required because send_responses_streaming does not accept checkpoint_id.
@@ -190,10 +190,10 @@ async def run_until_user_input_needed(
if initial_message:
print(f"\nStarting workflow with: {initial_message}\n")
- event_stream = workflow.run_stream(message=initial_message) # type: ignore[attr-defined]
+ event_stream = workflow.run(message=initial_message, stream=True) # type: ignore[attr-defined]
elif checkpoint_id:
print(f"\nResuming workflow from checkpoint: {checkpoint_id}\n")
- event_stream = workflow.run_stream(checkpoint_id=checkpoint_id) # type: ignore[attr-defined]
+ event_stream = workflow.run(checkpoint_id=checkpoint_id, stream=True) # type: ignore[attr-defined]
else:
raise ValueError("Must provide either initial_message or checkpoint_id")
@@ -257,7 +257,7 @@ async def resume_with_responses(
# Step 1: Restore the checkpoint to load pending requests into memory
# The checkpoint restoration re-emits pending RequestInfoEvents
restored_requests: list[RequestInfoEvent] = []
- async for event in workflow.run_stream(checkpoint_id=latest_checkpoint.checkpoint_id): # type: ignore[attr-defined]
+ async for event in workflow.run(checkpoint_id=latest_checkpoint.checkpoint_id, stream=True): # type: ignore[attr-defined]
if isinstance(event, RequestInfoEvent):
restored_requests.append(event)
if isinstance(event.data, HandoffAgentUserRequest):
diff --git a/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py b/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py
index 24dec9fb3e..6f8567d02c 100644
--- a/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py
+++ b/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py
@@ -334,7 +334,7 @@ async def main() -> None:
print("\n=== Stage 1: run until sub-workflow requests human review ===")
request_id: str | None = None
- async for event in workflow.run_stream("Contoso Gadget Launch"):
+ async for event in workflow.run("Contoso Gadget Launch", stream=True):
if isinstance(event, RequestInfoEvent) and request_id is None:
request_id = event.request_id
print(f"Captured review request id: {request_id}")
@@ -365,7 +365,7 @@ async def main() -> None:
workflow2 = build_parent_workflow(storage)
request_info_event: RequestInfoEvent | None = None
- async for event in workflow2.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id):
+ async for event in workflow2.run(checkpoint_id=resume_checkpoint.checkpoint_id, stream=True):
if isinstance(event, RequestInfoEvent):
request_info_event = event
diff --git a/python/samples/getting_started/workflows/checkpoint/workflow_as_agent_checkpoint.py b/python/samples/getting_started/workflows/checkpoint/workflow_as_agent_checkpoint.py
index c05ab2111e..d947330a19 100644
--- a/python/samples/getting_started/workflows/checkpoint/workflow_as_agent_checkpoint.py
+++ b/python/samples/getting_started/workflows/checkpoint/workflow_as_agent_checkpoint.py
@@ -5,11 +5,11 @@
Purpose:
This sample demonstrates how to use checkpointing with a workflow wrapped as an agent.
-It shows how to enable checkpoint storage when calling agent.run() or agent.run_stream(),
+It shows how to enable checkpoint storage when calling agent.run(),
allowing workflow execution state to be persisted and potentially resumed.
What you learn:
-- How to pass checkpoint_storage to WorkflowAgent.run() and run_stream()
+- How to pass checkpoint_storage to WorkflowAgent.run()
- How checkpoints are created during workflow-as-agent execution
- How to combine thread conversation history with workflow checkpointing
- How to resume a workflow-as-agent from a checkpoint
@@ -147,7 +147,7 @@ def create_assistant() -> ChatAgent:
print("[assistant]: ", end="", flush=True)
# Stream with checkpointing
- async for update in agent.run_stream(query, checkpoint_storage=checkpoint_storage):
+ async for update in agent.run(query, checkpoint_storage=checkpoint_storage, stream=True):
if update.text:
print(update.text, end="", flush=True)
diff --git a/python/samples/getting_started/workflows/composition/sub_workflow_kwargs.py b/python/samples/getting_started/workflows/composition/sub_workflow_kwargs.py
index 07e0f67d9d..bf95a980fd 100644
--- a/python/samples/getting_started/workflows/composition/sub_workflow_kwargs.py
+++ b/python/samples/getting_started/workflows/composition/sub_workflow_kwargs.py
@@ -18,10 +18,10 @@
This sample demonstrates how custom context (kwargs) flows from a parent workflow
through to agents in sub-workflows. When you pass kwargs to the parent workflow's
-run_stream() or run(), they automatically propagate to nested sub-workflows.
+run(), they automatically propagate to nested sub-workflows.
Key Concepts:
-- kwargs passed to parent workflow.run_stream() propagate to sub-workflows
+- kwargs passed to parent workflow.run() propagate to sub-workflows
- Sub-workflow agents receive the same kwargs as the parent workflow
- Works with nested WorkflowExecutor compositions at any depth
- Useful for passing authentication tokens, configuration, or request context
@@ -123,8 +123,9 @@ async def main() -> None:
# Run the OUTER workflow with kwargs
# These kwargs will automatically propagate to the inner sub-workflow
- async for event in outer_workflow.run_stream(
+ async for event in outer_workflow.run(
"Please fetch my profile data and then call the users service.",
+ stream=True,
user_token=user_token,
service_config=service_config,
):
diff --git a/python/samples/getting_started/workflows/composition/sub_workflow_request_interception.py b/python/samples/getting_started/workflows/composition/sub_workflow_request_interception.py
index 167ae2e950..b06a2ce82a 100644
--- a/python/samples/getting_started/workflows/composition/sub_workflow_request_interception.py
+++ b/python/samples/getting_started/workflows/composition/sub_workflow_request_interception.py
@@ -302,7 +302,7 @@ async def main() -> None:
# Execute the workflow
for email in test_emails:
print(f"\n🚀 Processing email to '{email.recipient}'")
- async for event in workflow.run_stream(email):
+ async for event in workflow.run(email, stream=True):
if isinstance(event, WorkflowOutputEvent):
print(f"🎉 Final result for '{email.recipient}': {'Delivered' if event.data else 'Blocked'}")
diff --git a/python/samples/getting_started/workflows/control-flow/multi_selection_edge_group.py b/python/samples/getting_started/workflows/control-flow/multi_selection_edge_group.py
index b998195759..23fd5601c4 100644
--- a/python/samples/getting_started/workflows/control-flow/multi_selection_edge_group.py
+++ b/python/samples/getting_started/workflows/control-flow/multi_selection_edge_group.py
@@ -276,7 +276,7 @@ def select_targets(analysis: AnalysisResult, target_ids: list[str]) -> list[str]
email = "Hello team, here are the updates for this week..."
# Print outputs and database events from streaming
- async for event in workflow.run_stream(email):
+ async for event in workflow.run(email, stream=True):
if isinstance(event, DatabaseEvent):
print(f"{event}")
elif isinstance(event, WorkflowOutputEvent):
diff --git a/python/samples/getting_started/workflows/control-flow/sequential_executors.py b/python/samples/getting_started/workflows/control-flow/sequential_executors.py
index e422009766..41bba945f3 100644
--- a/python/samples/getting_started/workflows/control-flow/sequential_executors.py
+++ b/python/samples/getting_started/workflows/control-flow/sequential_executors.py
@@ -16,7 +16,7 @@
Sample: Sequential workflow with streaming.
Two custom executors run in sequence. The first converts text to uppercase,
-the second reverses the text and completes the workflow. The run_stream loop prints events as they occur.
+the second reverses the text and completes the workflow. The streaming run loop prints events as they occur.
Purpose:
Show how to define explicit Executor classes with @handler methods, wire them in order with
@@ -75,7 +75,7 @@ async def main() -> None:
# Step 2: Stream events for a single input.
# The stream will include executor invoke and completion events, plus workflow outputs.
outputs: list[str] = []
- async for event in workflow.run_stream("hello world"):
+ async for event in workflow.run("hello world", stream=True):
print(f"Event: {event}")
if isinstance(event, WorkflowOutputEvent):
outputs.append(cast(str, event.data))
diff --git a/python/samples/getting_started/workflows/control-flow/sequential_streaming.py b/python/samples/getting_started/workflows/control-flow/sequential_streaming.py
index ce7bc92758..1e31bcafc8 100644
--- a/python/samples/getting_started/workflows/control-flow/sequential_streaming.py
+++ b/python/samples/getting_started/workflows/control-flow/sequential_streaming.py
@@ -9,7 +9,7 @@
Sample: Foundational sequential workflow with streaming using function-style executors.
Two lightweight steps run in order. The first converts text to uppercase.
-The second reverses the text and yields the workflow output. Events are printed as they arrive from run_stream.
+The second reverses the text and yields the workflow output. Events are printed as they arrive from a streaming run.
Purpose:
Show how to declare executors with the @executor decorator, connect them with WorkflowBuilder,
@@ -64,7 +64,7 @@ async def main():
)
# Step 2: Run the workflow and stream events in real time.
- async for event in workflow.run_stream("hello world"):
+ async for event in workflow.run("hello world", stream=True):
# You will see executor invoke and completion events as the workflow progresses.
print(f"Event: {event}")
if isinstance(event, WorkflowOutputEvent):
diff --git a/python/samples/getting_started/workflows/control-flow/simple_loop.py b/python/samples/getting_started/workflows/control-flow/simple_loop.py
index 348a014f9f..36a09241ed 100644
--- a/python/samples/getting_started/workflows/control-flow/simple_loop.py
+++ b/python/samples/getting_started/workflows/control-flow/simple_loop.py
@@ -142,7 +142,7 @@ async def main():
# Step 2: Run the workflow and print the events.
iterations = 0
- async for event in workflow.run_stream(NumberSignal.INIT):
+ async for event in workflow.run(NumberSignal.INIT, stream=True):
if isinstance(event, ExecutorCompletedEvent) and event.executor_id == "guess_number":
iterations += 1
print(f"Event: {event}")
diff --git a/python/samples/getting_started/workflows/control-flow/workflow_cancellation.py b/python/samples/getting_started/workflows/control-flow/workflow_cancellation.py
index 2ebd5bd128..e921fbe9cf 100644
--- a/python/samples/getting_started/workflows/control-flow/workflow_cancellation.py
+++ b/python/samples/getting_started/workflows/control-flow/workflow_cancellation.py
@@ -13,7 +13,7 @@
Purpose:
Show how to cancel a running workflow by wrapping it in an asyncio.Task. This pattern
-works with both workflow.run() and workflow.run_stream(). Useful for implementing
+works with both workflow.run() stream=True and stream=False. Useful for implementing
timeouts, graceful shutdown, or A2A executors that need cancellation support.
Prerequisites:
diff --git a/python/samples/getting_started/workflows/declarative/customer_support/main.py b/python/samples/getting_started/workflows/declarative/customer_support/main.py
index 84e36b771d..685ff905d5 100644
--- a/python/samples/getting_started/workflows/declarative/customer_support/main.py
+++ b/python/samples/getting_started/workflows/declarative/customer_support/main.py
@@ -256,7 +256,7 @@ async def main() -> None:
pending_request_id = None
else:
# Start workflow
- stream = workflow.run_stream(user_input)
+ stream = workflow.run(user_input, stream=True)
async for event in stream:
if isinstance(event, WorkflowOutputEvent):
diff --git a/python/samples/getting_started/workflows/declarative/deep_research/main.py b/python/samples/getting_started/workflows/declarative/deep_research/main.py
index b5efef8101..947c5d288c 100644
--- a/python/samples/getting_started/workflows/declarative/deep_research/main.py
+++ b/python/samples/getting_started/workflows/declarative/deep_research/main.py
@@ -192,7 +192,7 @@ async def main() -> None:
# Example input
task = "What is the weather like in Seattle and how does it compare to the average for this time of year?"
- async for event in workflow.run_stream(task):
+ async for event in workflow.run(task, stream=True):
if isinstance(event, WorkflowOutputEvent):
print(f"{event.data}", end="", flush=True)
diff --git a/python/samples/getting_started/workflows/declarative/function_tools/README.md b/python/samples/getting_started/workflows/declarative/function_tools/README.md
index c1dd8d64a5..42f3dc6497 100644
--- a/python/samples/getting_started/workflows/declarative/function_tools/README.md
+++ b/python/samples/getting_started/workflows/declarative/function_tools/README.md
@@ -68,7 +68,7 @@ Session Complete
1. Create an Azure OpenAI chat client
2. Create an agent with instructions and function tools
3. Register the agent with the workflow factory
-4. Load the workflow YAML and run it with `run_stream()`
+4. Load the workflow YAML and run it with `run()` and `stream=True`
```python
# Create the agent with tools
@@ -85,6 +85,6 @@ factory.register_agent("MenuAgent", menu_agent)
# Load and run the workflow
workflow = factory.create_workflow_from_yaml_path(workflow_path)
-async for event in workflow.run_stream(inputs={"userInput": "What is the soup of the day?"}):
+async for event in workflow.run(inputs={"userInput": "What is the soup of the day?"}, stream=True):
...
```
diff --git a/python/samples/getting_started/workflows/declarative/function_tools/main.py b/python/samples/getting_started/workflows/declarative/function_tools/main.py
index 180175063e..0fd8dce643 100644
--- a/python/samples/getting_started/workflows/declarative/function_tools/main.py
+++ b/python/samples/getting_started/workflows/declarative/function_tools/main.py
@@ -92,7 +92,7 @@ async def main():
response = ExternalInputResponse(user_input=user_input)
stream = workflow.send_responses_streaming({pending_request_id: response})
else:
- stream = workflow.run_stream({"userInput": user_input})
+ stream = workflow.run({"userInput": user_input}, stream=True)
pending_request_id = None
first_response = True
diff --git a/python/samples/getting_started/workflows/declarative/human_in_loop/main.py b/python/samples/getting_started/workflows/declarative/human_in_loop/main.py
index e9c0f90f83..aaf2faf613 100644
--- a/python/samples/getting_started/workflows/declarative/human_in_loop/main.py
+++ b/python/samples/getting_started/workflows/declarative/human_in_loop/main.py
@@ -21,11 +21,11 @@
async def run_with_streaming(workflow: Workflow) -> None:
- """Demonstrate streaming workflow execution with run_stream()."""
- print("\n=== Streaming Execution (run_stream) ===")
+ """Demonstrate streaming workflow execution."""
+ print("\n=== Streaming Execution ===")
print("-" * 40)
- async for event in workflow.run_stream({}):
+ async for event in workflow.run({}, stream=True):
# WorkflowOutputEvent wraps the actual output data
if isinstance(event, WorkflowOutputEvent):
data = event.data
diff --git a/python/samples/getting_started/workflows/declarative/marketing/main.py b/python/samples/getting_started/workflows/declarative/marketing/main.py
index e48d262076..639fbdddc3 100644
--- a/python/samples/getting_started/workflows/declarative/marketing/main.py
+++ b/python/samples/getting_started/workflows/declarative/marketing/main.py
@@ -84,7 +84,7 @@ async def main() -> None:
# Pass a simple string input - like .NET
product = "An eco-friendly stainless steel water bottle that keeps drinks cold for 24 hours."
- async for event in workflow.run_stream(product):
+ async for event in workflow.run(product, stream=True):
if isinstance(event, WorkflowOutputEvent):
print(f"{event.data}", end="", flush=True)
diff --git a/python/samples/getting_started/workflows/declarative/student_teacher/main.py b/python/samples/getting_started/workflows/declarative/student_teacher/main.py
index 746acaf009..dc252255a7 100644
--- a/python/samples/getting_started/workflows/declarative/student_teacher/main.py
+++ b/python/samples/getting_started/workflows/declarative/student_teacher/main.py
@@ -43,7 +43,7 @@
2. Gently point out errors without giving away the answer
3. Ask guiding questions to help them discover mistakes
4. Provide hints that lead toward understanding
-5. When the student demonstrates clear understanding, respond with "CONGRATULATIONS"
+5. When the student demonstrates clear understanding, respond with "CONGRATULATIONS"
followed by a summary of what they learned
Focus on building understanding, not just getting the right answer."""
@@ -81,7 +81,7 @@ async def main() -> None:
print("Student-Teacher Math Coaching Session")
print("=" * 50)
- async for event in workflow.run_stream("How would you compute the value of PI?"):
+ async for event in workflow.run("How would you compute the value of PI?", stream=True):
if isinstance(event, WorkflowOutputEvent):
print(f"{event.data}", flush=True, end="")
diff --git a/python/samples/getting_started/workflows/human-in-the-loop/agents_with_HITL.py b/python/samples/getting_started/workflows/human-in-the-loop/agents_with_HITL.py
index d2db9ac1c7..39b4d72086 100644
--- a/python/samples/getting_started/workflows/human-in-the-loop/agents_with_HITL.py
+++ b/python/samples/getting_started/workflows/human-in-the-loop/agents_with_HITL.py
@@ -204,8 +204,9 @@ async def main() -> None:
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run or send_responses_streaming.
- stream = workflow.run_stream(
- "Create a short launch blurb for the LumenX desk lamp. Emphasize adjustability and warm lighting."
+ stream = workflow.run(
+ "Create a short launch blurb for the LumenX desk lamp. Emphasize adjustability and warm lighting.",
+ stream=True,
)
pending_responses = await process_event_stream(stream)
diff --git a/python/samples/getting_started/workflows/human-in-the-loop/concurrent_request_info.py b/python/samples/getting_started/workflows/human-in-the-loop/concurrent_request_info.py
index f548515fe3..3591f54933 100644
--- a/python/samples/getting_started/workflows/human-in-the-loop/concurrent_request_info.py
+++ b/python/samples/getting_started/workflows/human-in-the-loop/concurrent_request_info.py
@@ -188,7 +188,7 @@ async def main() -> None:
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run or send_responses_streaming.
- stream = workflow.run_stream("Analyze the impact of large language models on software development.")
+ stream = workflow.run("Analyze the impact of large language models on software development.", stream=True)
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
diff --git a/python/samples/getting_started/workflows/human-in-the-loop/group_chat_request_info.py b/python/samples/getting_started/workflows/human-in-the-loop/group_chat_request_info.py
index 2e4c639bc9..64f45a1072 100644
--- a/python/samples/getting_started/workflows/human-in-the-loop/group_chat_request_info.py
+++ b/python/samples/getting_started/workflows/human-in-the-loop/group_chat_request_info.py
@@ -151,9 +151,10 @@ async def main() -> None:
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run or send_responses_streaming.
- stream = workflow.run_stream(
+ stream = workflow.run(
"Discuss how our team should approach adopting AI tools for productivity. "
- "Consider benefits, risks, and implementation strategies."
+ "Consider benefits, risks, and implementation strategies.",
+ stream=True,
)
pending_responses = await process_event_stream(stream)
diff --git a/python/samples/getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py b/python/samples/getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py
index 01801f0f72..ef03d7bd05 100644
--- a/python/samples/getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py
+++ b/python/samples/getting_started/workflows/human-in-the-loop/guessing_game_with_human_input.py
@@ -36,7 +36,7 @@
Demonstrate:
- Alternating turns between an AgentExecutor and a human, driven by events.
- Using Pydantic response_format to enforce structured JSON output from the agent instead of regex parsing.
-- Driving the loop in application code with run_stream and responses parameter.
+- Driving the loop in application code with run and responses parameter.
Prerequisites:
- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables.
@@ -206,7 +206,7 @@ async def main() -> None:
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run or send_responses_streaming.
- stream = workflow.run_stream("start")
+ stream = workflow.run("start", stream=True)
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
diff --git a/python/samples/getting_started/workflows/human-in-the-loop/sequential_request_info.py b/python/samples/getting_started/workflows/human-in-the-loop/sequential_request_info.py
index 913d2e514e..2c3c9ebe7f 100644
--- a/python/samples/getting_started/workflows/human-in-the-loop/sequential_request_info.py
+++ b/python/samples/getting_started/workflows/human-in-the-loop/sequential_request_info.py
@@ -126,7 +126,7 @@ async def main() -> None:
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run or send_responses_streaming.
- stream = workflow.run_stream("Write a brief introduction to artificial intelligence.")
+ stream = workflow.run("Write a brief introduction to artificial intelligence.", stream=True)
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
diff --git a/python/samples/getting_started/workflows/observability/executor_io_observation.py b/python/samples/getting_started/workflows/observability/executor_io_observation.py
index 0237f294f2..a8f7576fcb 100644
--- a/python/samples/getting_started/workflows/observability/executor_io_observation.py
+++ b/python/samples/getting_started/workflows/observability/executor_io_observation.py
@@ -91,7 +91,7 @@ async def main() -> None:
print("Running workflow with executor I/O observation...\n")
- async for event in workflow.run_stream("hello world"):
+ async for event in workflow.run("hello world", stream=True):
if isinstance(event, ExecutorInvokedEvent):
# The input message received by the executor is in event.data
print(f"[INVOKED] {event.executor_id}")
diff --git a/python/samples/getting_started/workflows/orchestration/magentic_human_plan_review.py b/python/samples/getting_started/workflows/orchestration/magentic_human_plan_review.py
new file mode 100644
index 0000000000..aa7b9b5f8c
--- /dev/null
+++ b/python/samples/getting_started/workflows/orchestration/magentic_human_plan_review.py
@@ -0,0 +1,145 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import asyncio
+import json
+from typing import cast
+
+from agent_framework import (
+ AgentRunUpdateEvent,
+ ChatAgent,
+ ChatMessage,
+ MagenticBuilder,
+ MagenticPlanReviewRequest,
+ RequestInfoEvent,
+ WorkflowOutputEvent,
+)
+from agent_framework.openai import OpenAIChatClient
+
+"""
+Sample: Magentic Orchestration with Human Plan Review
+
+This sample demonstrates how humans can review and provide feedback on plans
+generated by the Magentic workflow orchestrator. When plan review is enabled,
+the workflow requests human approval or revision before executing each plan.
+
+Key concepts:
+- with_plan_review(): Enables human review of generated plans
+- MagenticPlanReviewRequest: The event type for plan review requests
+- Human can choose to: approve the plan or provide revision feedback
+
+Plan review options:
+- approve(): Accept the proposed plan and continue execution
+- revise(feedback): Provide textual feedback to modify the plan
+
+Prerequisites:
+- OpenAI credentials configured for `OpenAIChatClient`.
+"""
+
+
+async def main() -> None:
+ researcher_agent = ChatAgent(
+ name="ResearcherAgent",
+ description="Specialist in research and information gathering",
+ instructions="You are a Researcher. You find information and gather facts.",
+ chat_client=OpenAIChatClient(model_id="gpt-4o"),
+ )
+
+ analyst_agent = ChatAgent(
+ name="AnalystAgent",
+ description="Data analyst who processes and summarizes research findings",
+ instructions="You are an Analyst. You analyze findings and create summaries.",
+ chat_client=OpenAIChatClient(model_id="gpt-4o"),
+ )
+
+ manager_agent = ChatAgent(
+ name="MagenticManager",
+ description="Orchestrator that coordinates the workflow",
+ instructions="You coordinate a team to complete tasks efficiently.",
+ chat_client=OpenAIChatClient(model_id="gpt-4o"),
+ )
+
+ print("\nBuilding Magentic Workflow with Human Plan Review...")
+
+ workflow = (
+ MagenticBuilder()
+ .participants([researcher_agent, analyst_agent])
+ .with_manager(
+ agent=manager_agent,
+ max_round_count=10,
+ max_stall_count=1,
+ max_reset_count=2,
+ )
+ .with_plan_review() # Request human input for plan review
+ .build()
+ )
+
+ task = "Research sustainable aviation fuel technology and summarize the findings."
+
+ print(f"\nTask: {task}")
+ print("\nStarting workflow execution...")
+ print("=" * 60)
+
+ pending_request: RequestInfoEvent | None = None
+ pending_responses: dict[str, object] | None = None
+ output_event: WorkflowOutputEvent | None = None
+
+ while not output_event:
+ if pending_responses is not None:
+ stream = workflow.send_responses_streaming(pending_responses)
+ else:
+ stream = workflow.run(task, stream=True)
+
+ last_message_id: str | None = None
+ async for event in stream:
+ if isinstance(event, AgentRunUpdateEvent):
+ message_id = event.data.message_id
+ if message_id != last_message_id:
+ if last_message_id is not None:
+ print("\n")
+ print(f"- {event.executor_id}:", end=" ", flush=True)
+ last_message_id = message_id
+ print(event.data, end="", flush=True)
+
+ elif isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
+ pending_request = event
+
+ elif isinstance(event, WorkflowOutputEvent):
+ output_event = event
+
+ pending_responses = None
+
+ # Handle plan review request if any
+ if pending_request is not None:
+ event_data = cast(MagenticPlanReviewRequest, pending_request.data)
+
+ print("\n\n[Magentic Plan Review Request]")
+ if event_data.current_progress is not None:
+ print("Current Progress Ledger:")
+ print(json.dumps(event_data.current_progress.to_dict(), indent=2))
+ print()
+ print(f"Proposed Plan:\n{event_data.plan.text}\n")
+ print("Please provide your feedback (press Enter to approve):")
+
+ reply = await asyncio.get_event_loop().run_in_executor(None, input, "> ")
+ if reply.strip() == "":
+ print("Plan approved.\n")
+ pending_responses = {pending_request.request_id: event_data.approve()}
+ else:
+ print("Plan revised by human.\n")
+ pending_responses = {pending_request.request_id: event_data.revise(reply)}
+ pending_request = None
+
+ print("\n" + "=" * 60)
+ print("WORKFLOW COMPLETED")
+ print("=" * 60)
+ print("Final Output:")
+ # The output of the Magentic workflow is a list of ChatMessages with only one final message
+ # generated by the orchestrator.
+ output_messages = cast(list[ChatMessage], output_event.data)
+ if output_messages:
+ output = output_messages[-1].text
+ print(output)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/python/samples/getting_started/workflows/parallelism/aggregate_results_of_different_types.py b/python/samples/getting_started/workflows/parallelism/aggregate_results_of_different_types.py
index 040d402d7b..8c01a81bc9 100644
--- a/python/samples/getting_started/workflows/parallelism/aggregate_results_of_different_types.py
+++ b/python/samples/getting_started/workflows/parallelism/aggregate_results_of_different_types.py
@@ -86,7 +86,7 @@ async def main() -> None:
# 2) Run the workflow
output: list[int | float] | None = None
- async for event in workflow.run_stream([random.randint(1, 100) for _ in range(10)]):
+ async for event in workflow.run([random.randint(1, 100) for _ in range(10)], stream=True):
if isinstance(event, WorkflowOutputEvent):
output = event.data
diff --git a/python/samples/getting_started/workflows/parallelism/fan_out_fan_in_edges.py b/python/samples/getting_started/workflows/parallelism/fan_out_fan_in_edges.py
index a7a856606a..0652fd86ed 100644
--- a/python/samples/getting_started/workflows/parallelism/fan_out_fan_in_edges.py
+++ b/python/samples/getting_started/workflows/parallelism/fan_out_fan_in_edges.py
@@ -11,6 +11,7 @@
Executor, # Base class for custom Python executors
ExecutorCompletedEvent,
ExecutorInvokedEvent,
+ Role, # Enum of chat roles (user, assistant, system)
WorkflowBuilder, # Fluent builder for wiring the workflow graph
WorkflowContext, # Per run context and event bus
WorkflowOutputEvent, # Event emitted when workflow yields output
@@ -44,7 +45,7 @@ class DispatchToExperts(Executor):
@handler
async def dispatch(self, prompt: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
# Wrap the incoming prompt as a user message for each expert and request a response.
- initial_message = ChatMessage("user", text=prompt)
+ initial_message = ChatMessage(Role.USER, text=prompt)
await ctx.send_message(AgentExecutorRequest(messages=[initial_message], should_respond=True))
@@ -139,7 +140,9 @@ async def main() -> None:
)
# 3) Run with a single prompt and print progress plus the final consolidated output
- async for event in workflow.run_stream("We are launching a new budget-friendly electric bike for urban commuters."):
+ async for event in workflow.run(
+ "We are launching a new budget-friendly electric bike for urban commuters.", stream=True
+ ):
if isinstance(event, ExecutorInvokedEvent):
# Show when executors are invoked and completed for lightweight observability.
print(f"{event.executor_id} invoked")
diff --git a/python/samples/getting_started/workflows/parallelism/map_reduce_and_visualization.py b/python/samples/getting_started/workflows/parallelism/map_reduce_and_visualization.py
index af2a6ad53d..c7ac2dee55 100644
--- a/python/samples/getting_started/workflows/parallelism/map_reduce_and_visualization.py
+++ b/python/samples/getting_started/workflows/parallelism/map_reduce_and_visualization.py
@@ -330,7 +330,7 @@ async def main():
raw_text = await f.read()
# Step 4: Run the workflow with the raw text as input.
- async for event in workflow.run_stream(raw_text):
+ async for event in workflow.run(raw_text, stream=True):
print(f"Event: {event}")
if isinstance(event, WorkflowOutputEvent):
print(f"Final Output: {event.data}")
diff --git a/python/samples/getting_started/workflows/state-management/workflow_kwargs.py b/python/samples/getting_started/workflows/state-management/workflow_kwargs.py
index 796164efce..aeb8bbeaf0 100644
--- a/python/samples/getting_started/workflows/state-management/workflow_kwargs.py
+++ b/python/samples/getting_started/workflows/state-management/workflow_kwargs.py
@@ -4,8 +4,9 @@
import json
from typing import Annotated, Any
-from agent_framework import ChatMessage, SequentialBuilder, WorkflowOutputEvent, tool
+from agent_framework import ChatMessage, WorkflowOutputEvent, tool
from agent_framework.openai import OpenAIChatClient
+from agent_framework.orchestrations import SequentialBuilder
from pydantic import Field
"""
@@ -15,7 +16,7 @@
through any workflow pattern to @tool functions using the **kwargs pattern.
Key Concepts:
-- Pass custom context as kwargs when invoking workflow.run_stream() or workflow.run()
+- Pass custom context as kwargs when invoking workflow.run()
- kwargs are stored in State and passed to all agent invocations
- @tool functions receive kwargs via **kwargs parameter
- Works with Sequential, Concurrent, GroupChat, Handoff, and Magentic patterns
@@ -112,10 +113,10 @@ async def main() -> None:
print("-" * 70)
# Run workflow with kwargs - these will flow through to tools
- async for event in workflow.run_stream(
+ async for event in workflow.run(
"Please get my user data and then call the users API endpoint.",
- custom_data=custom_data,
- user_token=user_token,
+ additional_function_arguments={"custom_data": custom_data, "user_token": user_token},
+ stream=True,
):
if isinstance(event, WorkflowOutputEvent):
output_data = event.data
diff --git a/python/samples/getting_started/workflows/tool-approval/concurrent_builder_tool_approval.py b/python/samples/getting_started/workflows/tool-approval/concurrent_builder_tool_approval.py
index fa56109a98..cfb425ae7e 100644
--- a/python/samples/getting_started/workflows/tool-approval/concurrent_builder_tool_approval.py
+++ b/python/samples/getting_started/workflows/tool-approval/concurrent_builder_tool_approval.py
@@ -158,9 +158,10 @@ async def main() -> None:
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run or send_responses_streaming.
- stream = workflow.run_stream(
+ stream = workflow.run(
"Manage my portfolio. Use a max of 5000 dollars to adjust my position using "
- "your best judgment based on market sentiment. No need to confirm trades with me."
+ "your best judgment based on market sentiment. No need to confirm trades with me.",
+ stream=True,
)
pending_responses = await process_event_stream(stream)
diff --git a/python/samples/getting_started/workflows/tool-approval/group_chat_builder_tool_approval.py b/python/samples/getting_started/workflows/tool-approval/group_chat_builder_tool_approval.py
index d16ee85b13..eeee1abfb2 100644
--- a/python/samples/getting_started/workflows/tool-approval/group_chat_builder_tool_approval.py
+++ b/python/samples/getting_started/workflows/tool-approval/group_chat_builder_tool_approval.py
@@ -169,7 +169,9 @@ async def main() -> None:
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run or send_responses_streaming.
- stream = workflow.run_stream("We need to deploy version 2.4.0 to production. Please coordinate the deployment.")
+ stream = workflow.run(
+ "We need to deploy version 2.4.0 to production. Please coordinate the deployment.", stream=True
+ )
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
diff --git a/python/samples/getting_started/workflows/tool-approval/sequential_builder_tool_approval.py b/python/samples/getting_started/workflows/tool-approval/sequential_builder_tool_approval.py
index 5493bc7588..d0e234e1db 100644
--- a/python/samples/getting_started/workflows/tool-approval/sequential_builder_tool_approval.py
+++ b/python/samples/getting_started/workflows/tool-approval/sequential_builder_tool_approval.py
@@ -119,7 +119,9 @@ async def main() -> None:
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run or send_responses_streaming.
- stream = workflow.run_stream("Check the schema and then update all orders with status 'pending' to 'processing'")
+ stream = workflow.run(
+ "Check the schema and then update all orders with status 'pending' to 'processing'", stream=True
+ )
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
diff --git a/python/samples/semantic-kernel-migration/README.md b/python/samples/semantic-kernel-migration/README.md
index 64c9d80aa5..c1fa894a4c 100644
--- a/python/samples/semantic-kernel-migration/README.md
+++ b/python/samples/semantic-kernel-migration/README.md
@@ -70,6 +70,6 @@ Swap the script path for any other workflow or process sample. Deactivate the sa
## Tips for Migration
- Keep the original SK sample open while iterating on the AF equivalent; the code is intentionally formatted so you can copy/paste across SDKs.
-- Threads/conversation state are explicit in AF. When porting SK code that relies on implicit thread reuse, call `agent.get_new_thread()` and pass it into each `run`/`run_stream` call.
+- Threads/conversation state are explicit in AF. When porting SK code that relies on implicit thread reuse, call `agent.get_new_thread()` and pass it into each `run` call.
- Tools map cleanly: SK `@kernel_function` plugins translate to AF `@tool` callables. Hosted tools (code interpreter, web search, MCP) are available only in AF—introduce them once parity is achieved.
- For multi-agent orchestration, AF workflows expose checkpoints and resume capabilities that SK Process/Team abstractions do not. Use the workflow samples as a blueprint when modernizing complex agent graphs.
diff --git a/python/samples/semantic-kernel-migration/chat_completion/03_chat_completion_thread_and_stream.py b/python/samples/semantic-kernel-migration/chat_completion/03_chat_completion_thread_and_stream.py
index 933910dd62..5d802867b1 100644
--- a/python/samples/semantic-kernel-migration/chat_completion/03_chat_completion_thread_and_stream.py
+++ b/python/samples/semantic-kernel-migration/chat_completion/03_chat_completion_thread_and_stream.py
@@ -53,9 +53,10 @@ async def run_agent_framework() -> None:
print("[AF]", first.text)
print("[AF][stream]", end=" ")
- async for chunk in chat_agent.run_stream(
+ async for chunk in chat_agent.run(
"Draft a 2 sentence blurb.",
thread=thread,
+ stream=True,
):
if chunk.text:
print(chunk.text, end="", flush=True)
diff --git a/python/samples/semantic-kernel-migration/copilot_studio/02_copilot_studio_streaming.py b/python/samples/semantic-kernel-migration/copilot_studio/02_copilot_studio_streaming.py
index d437ff807e..e0f02f682c 100644
--- a/python/samples/semantic-kernel-migration/copilot_studio/02_copilot_studio_streaming.py
+++ b/python/samples/semantic-kernel-migration/copilot_studio/02_copilot_studio_streaming.py
@@ -28,7 +28,7 @@ async def run_agent_framework() -> None:
)
# AF streaming provides incremental AgentResponseUpdate objects.
print("[AF][stream]", end=" ")
- async for update in agent.run_stream("Plan a day in Copenhagen for foodies."):
+ async for update in agent.run("Plan a day in Copenhagen for foodies.", stream=True):
if update.text:
print(update.text, end="", flush=True)
print()
diff --git a/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py b/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py
index b07a3393a8..efd3d80e5d 100644
--- a/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py
+++ b/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py
@@ -90,7 +90,7 @@ async def run_agent_framework_example(prompt: str) -> Sequence[list[ChatMessage]
workflow = ConcurrentBuilder().participants([physics, chemistry]).build()
outputs: list[list[ChatMessage]] = []
- async for event in workflow.run_stream(prompt):
+ async for event in workflow.run(prompt, stream=True):
if isinstance(event, WorkflowOutputEvent):
outputs.append(cast(list[ChatMessage], event.data))
diff --git a/python/samples/semantic-kernel-migration/orchestrations/group_chat.py b/python/samples/semantic-kernel-migration/orchestrations/group_chat.py
index 4ce31f3a04..76ab8ee692 100644
--- a/python/samples/semantic-kernel-migration/orchestrations/group_chat.py
+++ b/python/samples/semantic-kernel-migration/orchestrations/group_chat.py
@@ -239,7 +239,7 @@ async def run_agent_framework_example(task: str) -> str:
)
final_response = ""
- async for event in workflow.run_stream(task):
+ async for event in workflow.run(task, stream=True):
if isinstance(event, WorkflowOutputEvent):
data = event.data
if isinstance(data, list) and len(data) > 0:
diff --git a/python/samples/semantic-kernel-migration/orchestrations/handoff.py b/python/samples/semantic-kernel-migration/orchestrations/handoff.py
index a90c8acf14..f2333c0fb5 100644
--- a/python/samples/semantic-kernel-migration/orchestrations/handoff.py
+++ b/python/samples/semantic-kernel-migration/orchestrations/handoff.py
@@ -244,7 +244,7 @@ async def run_agent_framework_example(initial_task: str, scripted_responses: Seq
.build()
)
- events = await _drain_events(workflow.run_stream(initial_task))
+ events = await _drain_events(workflow.run(initial_task, stream=True))
pending = _collect_handoff_requests(events)
scripted_iter = iter(scripted_responses)
diff --git a/python/samples/semantic-kernel-migration/orchestrations/magentic.py b/python/samples/semantic-kernel-migration/orchestrations/magentic.py
index 3d9aa67ea8..db201da443 100644
--- a/python/samples/semantic-kernel-migration/orchestrations/magentic.py
+++ b/python/samples/semantic-kernel-migration/orchestrations/magentic.py
@@ -147,7 +147,7 @@ async def run_agent_framework_example(prompt: str) -> str | None:
workflow = MagenticBuilder().participants([researcher, coder]).with_manager(agent=manager_agent).build()
final_text: str | None = None
- async for event in workflow.run_stream(prompt):
+ async for event in workflow.run(prompt, stream=True):
if isinstance(event, WorkflowOutputEvent):
final_text = cast(str, event.data)
diff --git a/python/samples/semantic-kernel-migration/orchestrations/sequential.py b/python/samples/semantic-kernel-migration/orchestrations/sequential.py
index 3b66ab2538..e433c8c3d4 100644
--- a/python/samples/semantic-kernel-migration/orchestrations/sequential.py
+++ b/python/samples/semantic-kernel-migration/orchestrations/sequential.py
@@ -76,7 +76,7 @@ async def run_agent_framework_example(prompt: str) -> list[ChatMessage]:
workflow = SequentialBuilder().participants([writer, reviewer]).build()
conversation_outputs: list[list[ChatMessage]] = []
- async for event in workflow.run_stream(prompt):
+ async for event in workflow.run(prompt, stream=True):
if isinstance(event, WorkflowOutputEvent):
conversation_outputs.append(cast(list[ChatMessage], event.data))
diff --git a/python/samples/semantic-kernel-migration/processes/fan_out_fan_in_process.py b/python/samples/semantic-kernel-migration/processes/fan_out_fan_in_process.py
index 626421ddc9..cb27e53cc0 100644
--- a/python/samples/semantic-kernel-migration/processes/fan_out_fan_in_process.py
+++ b/python/samples/semantic-kernel-migration/processes/fan_out_fan_in_process.py
@@ -231,7 +231,7 @@ async def run_agent_framework_workflow_example() -> str | None:
)
final_text: str | None = None
- async for event in workflow.run_stream(CommonEvents.START_PROCESS):
+ async for event in workflow.run(CommonEvents.START_PROCESS, stream=True):
if isinstance(event, WorkflowOutputEvent):
final_text = cast(str, event.data)
diff --git a/python/samples/semantic-kernel-migration/processes/nested_process.py b/python/samples/semantic-kernel-migration/processes/nested_process.py
index 884ee6f4b0..40c682a805 100644
--- a/python/samples/semantic-kernel-migration/processes/nested_process.py
+++ b/python/samples/semantic-kernel-migration/processes/nested_process.py
@@ -256,7 +256,7 @@ async def run_agent_framework_nested_workflow(initial_message: str) -> Sequence[
)
results: list[str] = []
- async for event in outer_workflow.run_stream(initial_message):
+ async for event in outer_workflow.run(initial_message, stream=True):
if isinstance(event, WorkflowOutputEvent):
results.append(cast(str, event.data))
diff --git a/python/uv.lock b/python/uv.lock
index cf33068107..283dd5d191 100644
--- a/python/uv.lock
+++ b/python/uv.lock
@@ -191,7 +191,6 @@ dependencies = [
dev = [
{ name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
- { name = "pytest-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
[package.metadata]
@@ -201,7 +200,6 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
- { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" },
{ name = "uvicorn", specifier = ">=0.30.0" },
]
provides-extras = ["dev"]
@@ -453,6 +451,7 @@ all = [
{ name = "watchdog", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
dev = [
+ { name = "agent-framework-orchestrations", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "watchdog", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
@@ -460,6 +459,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "agent-framework-core", editable = "packages/core" },
+ { name = "agent-framework-orchestrations", marker = "extra == 'dev'", editable = "packages/orchestrations" },
{ name = "fastapi", specifier = ">=0.104.0" },
{ name = "pytest", marker = "extra == 'all'", specifier = ">=7.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
@@ -565,12 +565,6 @@ dev = [
{ name = "pre-commit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pyright", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
- { name = "pytest-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
- { name = "pytest-cov", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
- { name = "pytest-env", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
- { name = "pytest-retry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
- { name = "pytest-timeout", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
- { name = "pytest-xdist", extra = ["psutil"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "ruff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "tau2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -604,12 +598,6 @@ dev = [
{ name = "pre-commit", specifier = ">=3.7" },
{ name = "pyright", specifier = ">=1.1.402" },
{ name = "pytest", specifier = ">=8.4.1" },
- { name = "pytest-asyncio", specifier = ">=1.0.0" },
- { name = "pytest-cov", specifier = ">=6.2.1" },
- { name = "pytest-env", specifier = ">=1.1.5" },
- { name = "pytest-retry", specifier = ">=1" },
- { name = "pytest-timeout", specifier = ">=2.3.1" },
- { name = "pytest-xdist", extras = ["psutil"], specifier = ">=3.8.0" },
{ name = "rich" },
{ name = "ruff", specifier = ">=0.11.8" },
{ name = "tau2", git = "https://github.com/sierra-research/tau2-bench?rev=5ba9e3e56db57c5e4114bf7f901291f09b2c5619" },
@@ -1470,7 +1458,7 @@ name = "clr-loader"
version = "0.2.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
+ { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/24/c12faf3f61614b3131b5c98d3bf0d376b49c7feaa73edca559aeb2aee080/clr_loader-0.2.10.tar.gz", hash = "sha256:81f114afbc5005bafc5efe5af1341d400e22137e275b042a8979f3feb9fc9446", size = 83605, upload-time = "2026-01-03T23:13:06.984Z" }
wheels = [
@@ -1973,7 +1961,7 @@ name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
+ { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
@@ -2434,6 +2422,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/65/5b235b40581ad75ab97dcd8b4218022ae8e3ab77c13c919f1a1dfe9171fd/greenlet-3.3.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:04bee4775f40ecefcdaa9d115ab44736cd4b9c5fba733575bfe9379419582e13", size = 273723, upload-time = "2026-01-23T15:30:37.521Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ad/eb4729b85cba2d29499e0a04ca6fbdd8f540afd7be142fd571eea43d712f/greenlet-3.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e1457f4fed12a50e427988a07f0f9df53cf0ee8da23fab16e6732c2ec909d4", size = 574874, upload-time = "2026-01-23T16:00:54.551Z" },
{ url = "https://files.pythonhosted.org/packages/87/32/57cad7fe4c8b82fdaa098c89498ef85ad92dfbb09d5eb713adedfc2ae1f5/greenlet-3.3.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:070472cd156f0656f86f92e954591644e158fd65aa415ffbe2d44ca77656a8f5", size = 586309, upload-time = "2026-01-23T16:05:25.18Z" },
+ { url = "https://files.pythonhosted.org/packages/66/66/f041005cb87055e62b0d68680e88ec1a57f4688523d5e2fb305841bc8307/greenlet-3.3.1-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1108b61b06b5224656121c3c8ee8876161c491cbe74e5c519e0634c837cf93d5", size = 597461, upload-time = "2026-01-23T16:15:51.943Z" },
{ url = "https://files.pythonhosted.org/packages/87/eb/8a1ec2da4d55824f160594a75a9d8354a5fe0a300fb1c48e7944265217e1/greenlet-3.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a300354f27dd86bae5fbf7002e6dd2b3255cd372e9242c933faf5e859b703fe", size = 586985, upload-time = "2026-01-23T15:32:47.968Z" },
{ url = "https://files.pythonhosted.org/packages/15/1c/0621dd4321dd8c351372ee8f9308136acb628600658a49be1b7504208738/greenlet-3.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e84b51cbebf9ae573b5fbd15df88887815e3253fc000a7d0ff95170e8f7e9729", size = 1547271, upload-time = "2026-01-23T16:04:18.977Z" },
{ url = "https://files.pythonhosted.org/packages/9d/53/24047f8924c83bea7a59c8678d9571209c6bfe5f4c17c94a78c06024e9f2/greenlet-3.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0093bd1a06d899892427217f0ff2a3c8f306182b8c754336d32e2d587c131b4", size = 1613427, upload-time = "2026-01-23T15:33:44.428Z" },
@@ -2441,6 +2430,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-01-23T16:00:56.213Z" },
{ url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26.365Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ae/e2d5f0e59b94a2269b68a629173263fa40b63da32f5c231307c349315871/greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f", size = 601161, upload-time = "2026-01-23T16:15:53.456Z" },
{ url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-01-23T15:32:49.411Z" },
{ url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20.867Z" },
{ url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45.743Z" },
@@ -2449,6 +2439,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" },
{ url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" },
{ url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" },
{ url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" },
{ url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" },
{ url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" },
@@ -2457,6 +2448,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" },
{ url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" },
{ url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" },
+ { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" },
{ url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" },
{ url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" },
{ url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" },
@@ -2465,6 +2457,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" },
{ url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" },
{ url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" },
{ url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" },
{ url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" },
{ url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" },
@@ -2473,6 +2466,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" },
{ url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" },
{ url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" },
{ url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" },
{ url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" },
{ url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" },
@@ -3213,7 +3207,7 @@ wheels = [
[[package]]
name = "litellm"
-version = "1.81.7"
+version = "1.81.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -3229,9 +3223,9 @@ dependencies = [
{ name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "tokenizers", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/77/69/cfa8a1d68cd10223a9d9741c411e131aece85c60c29c1102d762738b3e5c/litellm-1.81.7.tar.gz", hash = "sha256:442ff38708383ebee21357b3d936e58938172bae892f03bc5be4019ed4ff4a17", size = 14039864, upload-time = "2026-02-03T19:43:10.633Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/eb/1d/e8f95dd1fc0eed36f2698ca82d8a0693d5388c6f2f1718f3f5ed472daaf4/litellm-1.81.8.tar.gz", hash = "sha256:5cc6547697748b8ca38d17d755662871da125df6e378cc987eaf2208a15626fb", size = 14066801, upload-time = "2026-02-05T05:56:03.37Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/60/95/8cecc7e6377171e4ac96f23d65236af8706d99c1b7b71a94c72206672810/litellm-1.81.7-py3-none-any.whl", hash = "sha256:58466c88c3289c6a3830d88768cf8f307581d9e6c87861de874d1128bb2de90d", size = 12254178, upload-time = "2026-02-03T19:43:08.035Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/5a/6f391c2f251553dae98b6edca31c070d7e2291cef6153ae69e0688159093/litellm-1.81.8-py3-none-any.whl", hash = "sha256:78cca92f36bc6c267c191d1fe1e2630c812bff6daec32c58cade75748c2692f6", size = 12286316, upload-time = "2026-02-05T05:56:00.248Z" },
]
[package.optional-dependencies]
@@ -3273,11 +3267,11 @@ wheels = [
[[package]]
name = "litellm-proxy-extras"
-version = "0.4.29"
+version = "0.4.30"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/42/c5/9c4325452b3b3fc144e942f0f0e6582374d588f3159a0706594e3422943c/litellm_proxy_extras-0.4.29.tar.gz", hash = "sha256:1a8266911e0546f1e17e6714ca20b72e9fef47c1683f9c16399cf2d1786437a0", size = 23561, upload-time = "2026-01-31T23:13:58.707Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/83/a1/00d2e91a7a91335a7d7f43dfb8316142879782c22ef59eca5d0ced055bf0/litellm_proxy_extras-0.4.30.tar.gz", hash = "sha256:5d32f8dc3d37d36fb15ab6995fea706dd8a453ff7f12e70b47cba35e5368da10", size = 23752, upload-time = "2026-02-05T03:54:00.351Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b0/d6/7393367fdf4b65d80ba0c32d517743a7aa8975a36b32cc70a0352b9514aa/litellm_proxy_extras-0.4.29-py3-none-any.whl", hash = "sha256:c36c1b69675c61acccc6b61dd610eb37daeb72c6fd819461cefb5b0cc7e0550f", size = 50734, upload-time = "2026-01-31T23:13:56.986Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/80/5b7ae7b39a79ca79722dd9049b3b4227b4540cb97006c8ef26c43af74db8/litellm_proxy_extras-0.4.30-py3-none-any.whl", hash = "sha256:0b7df68f0968eb817462b847eaee81bba23d935adb2e84d2e342a77711887051", size = 51217, upload-time = "2026-02-05T03:54:02.128Z" },
]
[[package]]
@@ -4728,8 +4722,8 @@ name = "powerfx"
version = "0.0.34"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
- { name = "pythonnet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
+ { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
+ { name = "pythonnet", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/fb/6c4bf87e0c74ca1c563921ce89ca1c5785b7576bca932f7255cdf81082a7/powerfx-0.0.34.tar.gz", hash = "sha256:956992e7afd272657ed16d80f4cad24ec95d9e4a79fb9dfa4a068a09e136af32", size = 3237555, upload-time = "2025-12-22T15:50:59.682Z" }
wheels = [
@@ -5396,7 +5390,7 @@ name = "pythonnet"
version = "3.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "clr-loader", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
+ { name = "clr-loader", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" }
wheels = [
@@ -6540,11 +6534,11 @@ dependencies = [
[[package]]
name = "tenacity"
-version = "9.1.2"
+version = "9.1.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1e/4a/c3357c8742f361785e3702bb4c9c68c4cb37a80aa657640b820669be5af1/tenacity-9.1.3.tar.gz", hash = "sha256:a6724c947aa717087e2531f883bde5c9188f603f6669a9b8d54eb998e604c12a", size = 49002, upload-time = "2026-02-05T06:33:12.866Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" },
+ { url = "https://files.pythonhosted.org/packages/64/6b/cdc85edb15e384d8e934aad89638cc8646e118c80de94c60125d0fc0a185/tenacity-9.1.3-py3-none-any.whl", hash = "sha256:51171cfc6b8a7826551e2f029426b10a6af189c5ac6986adcd7eb36d42f17954", size = 28858, upload-time = "2026-02-05T06:33:11.219Z" },
]
[[package]]