From 7f3b0215aa8274ccc9393dae7c92324a0b485153 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Mon, 24 Nov 2025 08:54:57 -0600 Subject: [PATCH 01/18] add limition to py --- .github/instructions/python-lang.instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/instructions/python-lang.instructions.md b/.github/instructions/python-lang.instructions.md index c37b99c7..3d706879 100644 --- a/.github/instructions/python-lang.instructions.md +++ b/.github/instructions/python-lang.instructions.md @@ -1,5 +1,5 @@ --- -applyTo: '**' +applyTo: '**/*.py' --- # Python Language Guide From b14ea379ef62979d57754eb92e9269817675fa7b Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Mon, 24 Nov 2025 08:55:07 -0600 Subject: [PATCH 02/18] rmv mjs node --- application/single_app/static/js/validateAgent.mjs | 1 - application/single_app/static/js/validatePlugin.mjs | 1 - 2 files changed, 2 deletions(-) delete mode 100644 application/single_app/static/js/validateAgent.mjs delete mode 100644 application/single_app/static/js/validatePlugin.mjs diff --git a/application/single_app/static/js/validateAgent.mjs b/application/single_app/static/js/validateAgent.mjs deleted file mode 100644 index a65b75a9..00000000 --- a/application/single_app/static/js/validateAgent.mjs +++ /dev/null @@ -1 +0,0 @@ -"use strict";export const validate = validate10;export default validate10;const schema11 = {"$schema":"http://json-schema.org/draft-07/schema#","$ref":"#/definitions/Agent","definitions":{"Agent":{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}","description":"Agent ID = GUID (UUID v4 pattern) and possible userId/groupId"},"user_id":{"type":"string","description":"User ID that owns this personal agent"},"last_updated":{"type":"string","description":"ISO timestamp of last update"},"name":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","description":"Alphanumeric, underscore, and dash only"},"display_name":{"type":"string"},"description":{"type":"string"},"azure_openai_gpt_endpoint":{"type":"string"},"azure_openai_gpt_key":{"type":"string"},"azure_openai_gpt_deployment":{"type":"string"},"azure_openai_gpt_api_version":{"type":"string"},"azure_agent_apim_gpt_endpoint":{"type":"string"},"azure_agent_apim_gpt_subscription_key":{"type":"string"},"azure_agent_apim_gpt_deployment":{"type":"string"},"azure_agent_apim_gpt_api_version":{"type":"string"},"enable_agent_gpt_apim":{"type":"boolean"},"default_agent":{"type":"boolean","description":"(deprecated) Use selected_agent for agent selection."},"is_global":{"type":"boolean","description":"True if this agent is a global agent; required for agent selection and UI badging.","default":false},"instructions":{"type":"string"},"actions_to_load":{"type":"array","items":{"type":"string"}},"other_settings":{"type":"object"},"max_completion_tokens":{"type":"integer","minimum":-1,"maximum":512000,"default":4096}},"required":["id","name","display_name","description","is_global","instructions","actions_to_load","other_settings","max_completion_tokens"],"title":"Agent"}}};const schema12 = {"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}","description":"Agent ID = GUID (UUID v4 pattern) and possible userId/groupId"},"user_id":{"type":"string","description":"User ID that owns this personal agent"},"last_updated":{"type":"string","description":"ISO timestamp of last update"},"name":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","description":"Alphanumeric, underscore, and dash only"},"display_name":{"type":"string"},"description":{"type":"string"},"azure_openai_gpt_endpoint":{"type":"string"},"azure_openai_gpt_key":{"type":"string"},"azure_openai_gpt_deployment":{"type":"string"},"azure_openai_gpt_api_version":{"type":"string"},"azure_agent_apim_gpt_endpoint":{"type":"string"},"azure_agent_apim_gpt_subscription_key":{"type":"string"},"azure_agent_apim_gpt_deployment":{"type":"string"},"azure_agent_apim_gpt_api_version":{"type":"string"},"enable_agent_gpt_apim":{"type":"boolean"},"default_agent":{"type":"boolean","description":"(deprecated) Use selected_agent for agent selection."},"is_global":{"type":"boolean","description":"True if this agent is a global agent; required for agent selection and UI badging.","default":false},"instructions":{"type":"string"},"actions_to_load":{"type":"array","items":{"type":"string"}},"other_settings":{"type":"object"},"max_completion_tokens":{"type":"integer","minimum":-1,"maximum":512000,"default":4096}},"required":["id","name","display_name","description","is_global","instructions","actions_to_load","other_settings","max_completion_tokens"],"title":"Agent"};const func2 = Object.prototype.hasOwnProperty;const pattern0 = new RegExp("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", "u");const pattern1 = new RegExp("^[A-Za-z0-9_-]+$", "u");function validate10(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;const _errs0 = errors;if(errors === _errs0){if(data && typeof data == "object" && !Array.isArray(data)){let missing0;if((((((((((data.id === undefined) && (missing0 = "id")) || ((data.name === undefined) && (missing0 = "name"))) || ((data.display_name === undefined) && (missing0 = "display_name"))) || ((data.description === undefined) && (missing0 = "description"))) || ((data.is_global === undefined) && (missing0 = "is_global"))) || ((data.instructions === undefined) && (missing0 = "instructions"))) || ((data.actions_to_load === undefined) && (missing0 = "actions_to_load"))) || ((data.other_settings === undefined) && (missing0 = "other_settings"))) || ((data.max_completion_tokens === undefined) && (missing0 = "max_completion_tokens"))){validate10.errors = [{instancePath,schemaPath:"#/definitions/Agent/required",keyword:"required",params:{missingProperty: missing0},message:"must have required property '"+missing0+"'"}];return false;}else {const _errs2 = errors;for(const key0 in data){if(!(func2.call(schema12.properties, key0))){validate10.errors = [{instancePath,schemaPath:"#/definitions/Agent/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"}];return false;break;}}if(_errs2 === errors){if(data.id !== undefined){let data0 = data.id;const _errs3 = errors;if(errors === _errs3){if(typeof data0 === "string"){if(!pattern0.test(data0)){validate10.errors = [{instancePath:instancePath+"/id",schemaPath:"#/definitions/Agent/properties/id/pattern",keyword:"pattern",params:{pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"},message:"must match pattern \""+"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"+"\""}];return false;}}else {validate10.errors = [{instancePath:instancePath+"/id",schemaPath:"#/definitions/Agent/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}}var valid1 = _errs3 === errors;}else {var valid1 = true;}if(valid1){if(data.user_id !== undefined){const _errs5 = errors;if(typeof data.user_id !== "string"){validate10.errors = [{instancePath:instancePath+"/user_id",schemaPath:"#/definitions/Agent/properties/user_id/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs5 === errors;}else {var valid1 = true;}if(valid1){if(data.last_updated !== undefined){const _errs7 = errors;if(typeof data.last_updated !== "string"){validate10.errors = [{instancePath:instancePath+"/last_updated",schemaPath:"#/definitions/Agent/properties/last_updated/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs7 === errors;}else {var valid1 = true;}if(valid1){if(data.name !== undefined){let data3 = data.name;const _errs9 = errors;if(errors === _errs9){if(typeof data3 === "string"){if(!pattern1.test(data3)){validate10.errors = [{instancePath:instancePath+"/name",schemaPath:"#/definitions/Agent/properties/name/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9_-]+$"},message:"must match pattern \""+"^[A-Za-z0-9_-]+$"+"\""}];return false;}}else {validate10.errors = [{instancePath:instancePath+"/name",schemaPath:"#/definitions/Agent/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}}var valid1 = _errs9 === errors;}else {var valid1 = true;}if(valid1){if(data.display_name !== undefined){const _errs11 = errors;if(typeof data.display_name !== "string"){validate10.errors = [{instancePath:instancePath+"/display_name",schemaPath:"#/definitions/Agent/properties/display_name/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs11 === errors;}else {var valid1 = true;}if(valid1){if(data.description !== undefined){const _errs13 = errors;if(typeof data.description !== "string"){validate10.errors = [{instancePath:instancePath+"/description",schemaPath:"#/definitions/Agent/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs13 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_openai_gpt_endpoint !== undefined){const _errs15 = errors;if(typeof data.azure_openai_gpt_endpoint !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_openai_gpt_endpoint",schemaPath:"#/definitions/Agent/properties/azure_openai_gpt_endpoint/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs15 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_openai_gpt_key !== undefined){const _errs17 = errors;if(typeof data.azure_openai_gpt_key !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_openai_gpt_key",schemaPath:"#/definitions/Agent/properties/azure_openai_gpt_key/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs17 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_openai_gpt_deployment !== undefined){const _errs19 = errors;if(typeof data.azure_openai_gpt_deployment !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_openai_gpt_deployment",schemaPath:"#/definitions/Agent/properties/azure_openai_gpt_deployment/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs19 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_openai_gpt_api_version !== undefined){const _errs21 = errors;if(typeof data.azure_openai_gpt_api_version !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_openai_gpt_api_version",schemaPath:"#/definitions/Agent/properties/azure_openai_gpt_api_version/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs21 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_agent_apim_gpt_endpoint !== undefined){const _errs23 = errors;if(typeof data.azure_agent_apim_gpt_endpoint !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_agent_apim_gpt_endpoint",schemaPath:"#/definitions/Agent/properties/azure_agent_apim_gpt_endpoint/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs23 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_agent_apim_gpt_subscription_key !== undefined){const _errs25 = errors;if(typeof data.azure_agent_apim_gpt_subscription_key !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_agent_apim_gpt_subscription_key",schemaPath:"#/definitions/Agent/properties/azure_agent_apim_gpt_subscription_key/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs25 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_agent_apim_gpt_deployment !== undefined){const _errs27 = errors;if(typeof data.azure_agent_apim_gpt_deployment !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_agent_apim_gpt_deployment",schemaPath:"#/definitions/Agent/properties/azure_agent_apim_gpt_deployment/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs27 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_agent_apim_gpt_api_version !== undefined){const _errs29 = errors;if(typeof data.azure_agent_apim_gpt_api_version !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_agent_apim_gpt_api_version",schemaPath:"#/definitions/Agent/properties/azure_agent_apim_gpt_api_version/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs29 === errors;}else {var valid1 = true;}if(valid1){if(data.enable_agent_gpt_apim !== undefined){const _errs31 = errors;if(typeof data.enable_agent_gpt_apim !== "boolean"){validate10.errors = [{instancePath:instancePath+"/enable_agent_gpt_apim",schemaPath:"#/definitions/Agent/properties/enable_agent_gpt_apim/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}];return false;}var valid1 = _errs31 === errors;}else {var valid1 = true;}if(valid1){if(data.default_agent !== undefined){const _errs33 = errors;if(typeof data.default_agent !== "boolean"){validate10.errors = [{instancePath:instancePath+"/default_agent",schemaPath:"#/definitions/Agent/properties/default_agent/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}];return false;}var valid1 = _errs33 === errors;}else {var valid1 = true;}if(valid1){if(data.is_global !== undefined){const _errs35 = errors;if(typeof data.is_global !== "boolean"){validate10.errors = [{instancePath:instancePath+"/is_global",schemaPath:"#/definitions/Agent/properties/is_global/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}];return false;}var valid1 = _errs35 === errors;}else {var valid1 = true;}if(valid1){if(data.instructions !== undefined){const _errs37 = errors;if(typeof data.instructions !== "string"){validate10.errors = [{instancePath:instancePath+"/instructions",schemaPath:"#/definitions/Agent/properties/instructions/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs37 === errors;}else {var valid1 = true;}if(valid1){if(data.actions_to_load !== undefined){let data18 = data.actions_to_load;const _errs39 = errors;if(errors === _errs39){if(Array.isArray(data18)){var valid2 = true;const len0 = data18.length;for(let i0=0; i0 512000 || isNaN(data21)){validate10.errors = [{instancePath:instancePath+"/max_completion_tokens",schemaPath:"#/definitions/Agent/properties/max_completion_tokens/maximum",keyword:"maximum",params:{comparison: "<=", limit: 512000},message:"must be <= 512000"}];return false;}else {if(data21 < -1 || isNaN(data21)){validate10.errors = [{instancePath:instancePath+"/max_completion_tokens",schemaPath:"#/definitions/Agent/properties/max_completion_tokens/minimum",keyword:"minimum",params:{comparison: ">=", limit: -1},message:"must be >= -1"}];return false;}}}}var valid1 = _errs45 === errors;}else {var valid1 = true;}}}}}}}}}}}}}}}}}}}}}}}}else {validate10.errors = [{instancePath,schemaPath:"#/definitions/Agent/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}validate10.errors = vErrors;return errors === 0;} \ No newline at end of file diff --git a/application/single_app/static/js/validatePlugin.mjs b/application/single_app/static/js/validatePlugin.mjs deleted file mode 100644 index ae4ad016..00000000 --- a/application/single_app/static/js/validatePlugin.mjs +++ /dev/null @@ -1 +0,0 @@ -"use strict";export const validate = validate11;export default validate11;const schema13 = {"$schema":"http://json-schema.org/draft-07/schema#","$ref":"#/definitions/Plugin","definitions":{"Plugin":{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","description":"Plugin unique identifier (UUID)"},"user_id":{"type":"string","description":"User ID that owns this personal plugin"},"last_updated":{"type":"string","description":"ISO timestamp of last update"},"name":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","description":"Alphanumeric, underscore, and dash only"},"displayName":{"type":"string","description":"Human-readable display name for the plugin"},"type":{"type":"string"},"description":{"type":"string"},"endpoint":{"type":"string"},"auth":{"type":"object","properties":{"type":{"type":"string","enum":["NoAuth","key","identity","user","servicePrincipal","connection_string","basic","username_password"],"description":"Auth type must be 'key', 'user', 'identity', 'servicePrincipal', 'connection_string', 'basic', or 'username_password'"},"key":{"type":"string","description":"The secret value for the plugin should be stored here, such as a SQL connection string, a password for a service principal or username/password combination"},"identity":{"type":"string","description":"This could be the Id of an (managed) identity, a user name, or similar to pair with the key, in most situations"},"tenantId":{"type":"string","description":"The Azure AD tenant ID used with Service Principal authentication"}},"additionalProperties":false,"allOf":[{"if":{"properties":{"type":{"const":"NoAuth"}}},"then":{"required":["type"]}},{"if":{"properties":{"type":{"const":"key"}}},"then":{"required":["type","key"]}},{"if":{"properties":{"type":{"const":"identity"}}},"then":{"required":["type","identity"]}},{"if":{"properties":{"type":{"const":"user"}}},"then":{"required":["type"]}},{"if":{"properties":{"type":{"const":"servicePrincipal"}}},"then":{"required":["type","tenantId","identity","key"]}},{"if":{"properties":{"type":{"const":"connection_string"}}},"then":{"required":["type","key"]}},{"if":{"properties":{"type":{"const":"basic"}}},"then":{"required":["type","key","identity"]}},{"if":{"properties":{"type":{"const":"username_password"}}},"then":{"required":["type","key","identity"]}},{"required":["type"]}]},"metadata":{"type":"object","description":"Arbitrary metadata","additionalProperties":true},"additionalFields":{"type":"object","description":"Additional fields for plugin configuration based on plugin type. See plugin documentation for details. Any fields named __Secret (double underscore) will be stored in key vault if the feature is enabled.","additionalProperties":true}},"required":["name","type","description","endpoint","auth","metadata","additionalFields"],"title":"Plugin"}}};const schema14 = {"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","description":"Plugin unique identifier (UUID)"},"user_id":{"type":"string","description":"User ID that owns this personal plugin"},"last_updated":{"type":"string","description":"ISO timestamp of last update"},"name":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","description":"Alphanumeric, underscore, and dash only"},"displayName":{"type":"string","description":"Human-readable display name for the plugin"},"type":{"type":"string"},"description":{"type":"string"},"endpoint":{"type":"string"},"auth":{"type":"object","properties":{"type":{"type":"string","enum":["NoAuth","key","identity","user","servicePrincipal","connection_string","basic","username_password"],"description":"Auth type must be 'key', 'user', 'identity', 'servicePrincipal', 'connection_string', 'basic', or 'username_password'"},"key":{"type":"string","description":"The secret value for the plugin should be stored here, such as a SQL connection string, a password for a service principal or username/password combination"},"identity":{"type":"string","description":"This could be the Id of an (managed) identity, a user name, or similar to pair with the key, in most situations"},"tenantId":{"type":"string","description":"The Azure AD tenant ID used with Service Principal authentication"}},"additionalProperties":false,"allOf":[{"if":{"properties":{"type":{"const":"NoAuth"}}},"then":{"required":["type"]}},{"if":{"properties":{"type":{"const":"key"}}},"then":{"required":["type","key"]}},{"if":{"properties":{"type":{"const":"identity"}}},"then":{"required":["type","identity"]}},{"if":{"properties":{"type":{"const":"user"}}},"then":{"required":["type"]}},{"if":{"properties":{"type":{"const":"servicePrincipal"}}},"then":{"required":["type","tenantId","identity","key"]}},{"if":{"properties":{"type":{"const":"connection_string"}}},"then":{"required":["type","key"]}},{"if":{"properties":{"type":{"const":"basic"}}},"then":{"required":["type","key","identity"]}},{"if":{"properties":{"type":{"const":"username_password"}}},"then":{"required":["type","key","identity"]}},{"required":["type"]}]},"metadata":{"type":"object","description":"Arbitrary metadata","additionalProperties":true},"additionalFields":{"type":"object","description":"Additional fields for plugin configuration based on plugin type. See plugin documentation for details. Any fields named __Secret (double underscore) will be stored in key vault if the feature is enabled.","additionalProperties":true}},"required":["name","type","description","endpoint","auth","metadata","additionalFields"],"title":"Plugin"};const func2 = Object.prototype.hasOwnProperty;const pattern1 = new RegExp("^[A-Za-z0-9_-]+$", "u");function validate11(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;const _errs0 = errors;if(errors === _errs0){if(data && typeof data == "object" && !Array.isArray(data)){let missing0;if((((((((data.name === undefined) && (missing0 = "name")) || ((data.type === undefined) && (missing0 = "type"))) || ((data.description === undefined) && (missing0 = "description"))) || ((data.endpoint === undefined) && (missing0 = "endpoint"))) || ((data.auth === undefined) && (missing0 = "auth"))) || ((data.metadata === undefined) && (missing0 = "metadata"))) || ((data.additionalFields === undefined) && (missing0 = "additionalFields"))){validate11.errors = [{instancePath,schemaPath:"#/definitions/Plugin/required",keyword:"required",params:{missingProperty: missing0},message:"must have required property '"+missing0+"'"}];return false;}else {const _errs2 = errors;for(const key0 in data){if(!(func2.call(schema14.properties, key0))){validate11.errors = [{instancePath,schemaPath:"#/definitions/Plugin/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"}];return false;break;}}if(_errs2 === errors){if(data.id !== undefined){const _errs3 = errors;if(typeof data.id !== "string"){validate11.errors = [{instancePath:instancePath+"/id",schemaPath:"#/definitions/Plugin/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs3 === errors;}else {var valid1 = true;}if(valid1){if(data.user_id !== undefined){const _errs5 = errors;if(typeof data.user_id !== "string"){validate11.errors = [{instancePath:instancePath+"/user_id",schemaPath:"#/definitions/Plugin/properties/user_id/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs5 === errors;}else {var valid1 = true;}if(valid1){if(data.last_updated !== undefined){const _errs7 = errors;if(typeof data.last_updated !== "string"){validate11.errors = [{instancePath:instancePath+"/last_updated",schemaPath:"#/definitions/Plugin/properties/last_updated/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs7 === errors;}else {var valid1 = true;}if(valid1){if(data.name !== undefined){let data3 = data.name;const _errs9 = errors;if(errors === _errs9){if(typeof data3 === "string"){if(!pattern1.test(data3)){validate11.errors = [{instancePath:instancePath+"/name",schemaPath:"#/definitions/Plugin/properties/name/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9_-]+$"},message:"must match pattern \""+"^[A-Za-z0-9_-]+$"+"\""}];return false;}}else {validate11.errors = [{instancePath:instancePath+"/name",schemaPath:"#/definitions/Plugin/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}}var valid1 = _errs9 === errors;}else {var valid1 = true;}if(valid1){if(data.displayName !== undefined){const _errs11 = errors;if(typeof data.displayName !== "string"){validate11.errors = [{instancePath:instancePath+"/displayName",schemaPath:"#/definitions/Plugin/properties/displayName/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs11 === errors;}else {var valid1 = true;}if(valid1){if(data.type !== undefined){const _errs13 = errors;if(typeof data.type !== "string"){validate11.errors = [{instancePath:instancePath+"/type",schemaPath:"#/definitions/Plugin/properties/type/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs13 === errors;}else {var valid1 = true;}if(valid1){if(data.description !== undefined){const _errs15 = errors;if(typeof data.description !== "string"){validate11.errors = [{instancePath:instancePath+"/description",schemaPath:"#/definitions/Plugin/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs15 === errors;}else {var valid1 = true;}if(valid1){if(data.endpoint !== undefined){const _errs17 = errors;if(typeof data.endpoint !== "string"){validate11.errors = [{instancePath:instancePath+"/endpoint",schemaPath:"#/definitions/Plugin/properties/endpoint/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs17 === errors;}else {var valid1 = true;}if(valid1){if(data.auth !== undefined){let data8 = data.auth;const _errs19 = errors;const _errs21 = errors;const _errs22 = errors;let valid3 = true;const _errs23 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("NoAuth" !== data8.type){const err0 = {};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}}var _valid0 = _errs23 === errors;errors = _errs22;if(vErrors !== null){if(_errs22){vErrors.length = _errs22;}else {vErrors = null;}}if(_valid0){const _errs25 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing1;if((data8.type === undefined) && (missing1 = "type")){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/0/then/required",keyword:"required",params:{missingProperty: missing1},message:"must have required property '"+missing1+"'"}];return false;}}var _valid0 = _errs25 === errors;valid3 = _valid0;}if(!valid3){const err1 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs21 === errors;if(valid2){const _errs26 = errors;const _errs27 = errors;let valid5 = true;const _errs28 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("key" !== data8.type){const err2 = {};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}}var _valid1 = _errs28 === errors;errors = _errs27;if(vErrors !== null){if(_errs27){vErrors.length = _errs27;}else {vErrors = null;}}if(_valid1){const _errs30 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing2;if(((data8.type === undefined) && (missing2 = "type")) || ((data8.key === undefined) && (missing2 = "key"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/1/then/required",keyword:"required",params:{missingProperty: missing2},message:"must have required property '"+missing2+"'"}];return false;}}var _valid1 = _errs30 === errors;valid5 = _valid1;}if(!valid5){const err3 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/1/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs26 === errors;if(valid2){const _errs31 = errors;const _errs32 = errors;let valid7 = true;const _errs33 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("identity" !== data8.type){const err4 = {};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}}var _valid2 = _errs33 === errors;errors = _errs32;if(vErrors !== null){if(_errs32){vErrors.length = _errs32;}else {vErrors = null;}}if(_valid2){const _errs35 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing3;if(((data8.type === undefined) && (missing3 = "type")) || ((data8.identity === undefined) && (missing3 = "identity"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/2/then/required",keyword:"required",params:{missingProperty: missing3},message:"must have required property '"+missing3+"'"}];return false;}}var _valid2 = _errs35 === errors;valid7 = _valid2;}if(!valid7){const err5 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/2/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs31 === errors;if(valid2){const _errs36 = errors;const _errs37 = errors;let valid9 = true;const _errs38 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("user" !== data8.type){const err6 = {};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}}var _valid3 = _errs38 === errors;errors = _errs37;if(vErrors !== null){if(_errs37){vErrors.length = _errs37;}else {vErrors = null;}}if(_valid3){const _errs40 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing4;if((data8.type === undefined) && (missing4 = "type")){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/3/then/required",keyword:"required",params:{missingProperty: missing4},message:"must have required property '"+missing4+"'"}];return false;}}var _valid3 = _errs40 === errors;valid9 = _valid3;}if(!valid9){const err7 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/3/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs36 === errors;if(valid2){const _errs41 = errors;const _errs42 = errors;let valid11 = true;const _errs43 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("servicePrincipal" !== data8.type){const err8 = {};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}}var _valid4 = _errs43 === errors;errors = _errs42;if(vErrors !== null){if(_errs42){vErrors.length = _errs42;}else {vErrors = null;}}if(_valid4){const _errs45 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing5;if(((((data8.type === undefined) && (missing5 = "type")) || ((data8.tenantId === undefined) && (missing5 = "tenantId"))) || ((data8.identity === undefined) && (missing5 = "identity"))) || ((data8.key === undefined) && (missing5 = "key"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/4/then/required",keyword:"required",params:{missingProperty: missing5},message:"must have required property '"+missing5+"'"}];return false;}}var _valid4 = _errs45 === errors;valid11 = _valid4;}if(!valid11){const err9 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/4/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs41 === errors;if(valid2){const _errs46 = errors;const _errs47 = errors;let valid13 = true;const _errs48 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("connection_string" !== data8.type){const err10 = {};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}}var _valid5 = _errs48 === errors;errors = _errs47;if(vErrors !== null){if(_errs47){vErrors.length = _errs47;}else {vErrors = null;}}if(_valid5){const _errs50 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing6;if(((data8.type === undefined) && (missing6 = "type")) || ((data8.key === undefined) && (missing6 = "key"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/5/then/required",keyword:"required",params:{missingProperty: missing6},message:"must have required property '"+missing6+"'"}];return false;}}var _valid5 = _errs50 === errors;valid13 = _valid5;}if(!valid13){const err11 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/5/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs46 === errors;if(valid2){const _errs51 = errors;const _errs52 = errors;let valid15 = true;const _errs53 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("basic" !== data8.type){const err12 = {};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}}var _valid6 = _errs53 === errors;errors = _errs52;if(vErrors !== null){if(_errs52){vErrors.length = _errs52;}else {vErrors = null;}}if(_valid6){const _errs55 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing7;if((((data8.type === undefined) && (missing7 = "type")) || ((data8.key === undefined) && (missing7 = "key"))) || ((data8.identity === undefined) && (missing7 = "identity"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/6/then/required",keyword:"required",params:{missingProperty: missing7},message:"must have required property '"+missing7+"'"}];return false;}}var _valid6 = _errs55 === errors;valid15 = _valid6;}if(!valid15){const err13 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/6/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs51 === errors;if(valid2){const _errs56 = errors;const _errs57 = errors;let valid17 = true;const _errs58 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("username_password" !== data8.type){const err14 = {};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}}var _valid7 = _errs58 === errors;errors = _errs57;if(vErrors !== null){if(_errs57){vErrors.length = _errs57;}else {vErrors = null;}}if(_valid7){const _errs60 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing8;if((((data8.type === undefined) && (missing8 = "type")) || ((data8.key === undefined) && (missing8 = "key"))) || ((data8.identity === undefined) && (missing8 = "identity"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/7/then/required",keyword:"required",params:{missingProperty: missing8},message:"must have required property '"+missing8+"'"}];return false;}}var _valid7 = _errs60 === errors;valid17 = _valid7;}if(!valid17){const err15 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/7/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs56 === errors;if(valid2){const _errs61 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing9;if((data8.type === undefined) && (missing9 = "type")){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/8/required",keyword:"required",params:{missingProperty: missing9},message:"must have required property '"+missing9+"'"}];return false;}}var valid2 = _errs61 === errors;}}}}}}}}if(errors === _errs19){if(data8 && typeof data8 == "object" && !Array.isArray(data8)){const _errs62 = errors;for(const key1 in data8){if(!((((key1 === "type") || (key1 === "key")) || (key1 === "identity")) || (key1 === "tenantId"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"}];return false;break;}}if(_errs62 === errors){if(data8.type !== undefined){let data17 = data8.type;const _errs63 = errors;if(typeof data17 !== "string"){validate11.errors = [{instancePath:instancePath+"/auth/type",schemaPath:"#/definitions/Plugin/properties/auth/properties/type/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}if(!((((((((data17 === "NoAuth") || (data17 === "key")) || (data17 === "identity")) || (data17 === "user")) || (data17 === "servicePrincipal")) || (data17 === "connection_string")) || (data17 === "basic")) || (data17 === "username_password"))){validate11.errors = [{instancePath:instancePath+"/auth/type",schemaPath:"#/definitions/Plugin/properties/auth/properties/type/enum",keyword:"enum",params:{allowedValues: schema14.properties.auth.properties.type.enum},message:"must be equal to one of the allowed values"}];return false;}var valid19 = _errs63 === errors;}else {var valid19 = true;}if(valid19){if(data8.key !== undefined){const _errs65 = errors;if(typeof data8.key !== "string"){validate11.errors = [{instancePath:instancePath+"/auth/key",schemaPath:"#/definitions/Plugin/properties/auth/properties/key/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid19 = _errs65 === errors;}else {var valid19 = true;}if(valid19){if(data8.identity !== undefined){const _errs67 = errors;if(typeof data8.identity !== "string"){validate11.errors = [{instancePath:instancePath+"/auth/identity",schemaPath:"#/definitions/Plugin/properties/auth/properties/identity/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid19 = _errs67 === errors;}else {var valid19 = true;}if(valid19){if(data8.tenantId !== undefined){const _errs69 = errors;if(typeof data8.tenantId !== "string"){validate11.errors = [{instancePath:instancePath+"/auth/tenantId",schemaPath:"#/definitions/Plugin/properties/auth/properties/tenantId/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid19 = _errs69 === errors;}else {var valid19 = true;}}}}}}else {validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}var valid1 = _errs19 === errors;}else {var valid1 = true;}if(valid1){if(data.metadata !== undefined){let data21 = data.metadata;const _errs71 = errors;if(errors === _errs71){if(data21 && typeof data21 == "object" && !Array.isArray(data21)){}else {validate11.errors = [{instancePath:instancePath+"/metadata",schemaPath:"#/definitions/Plugin/properties/metadata/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}var valid1 = _errs71 === errors;}else {var valid1 = true;}if(valid1){if(data.additionalFields !== undefined){let data22 = data.additionalFields;const _errs74 = errors;if(errors === _errs74){if(data22 && typeof data22 == "object" && !Array.isArray(data22)){}else {validate11.errors = [{instancePath:instancePath+"/additionalFields",schemaPath:"#/definitions/Plugin/properties/additionalFields/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}var valid1 = _errs74 === errors;}else {var valid1 = true;}}}}}}}}}}}}}}else {validate11.errors = [{instancePath,schemaPath:"#/definitions/Plugin/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}validate11.errors = vErrors;return errors === 0;} \ No newline at end of file From b15a032fcab21aee37ea1aad77373eb85ed413fb Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Mon, 24 Nov 2025 08:55:21 -0600 Subject: [PATCH 03/18] add agent templating poc --- application/single_app/config.py | 2 +- .../templates/_agent_config_info.html | 299 ++++++++++++++++++ .../single_app/templates/_agent_examples.html | 74 +++++ .../templates/_agent_examples_modal.html | 185 +++++++++++ .../single_app/templates/_agent_modal.html | 51 ++- .../single_app/templates/admin_settings.html | 8 +- .../templates/group_workspaces.html | 8 +- .../single_app/templates/workspace.html | 8 +- 8 files changed, 627 insertions(+), 8 deletions(-) create mode 100644 application/single_app/templates/_agent_config_info.html create mode 100644 application/single_app/templates/_agent_examples.html create mode 100644 application/single_app/templates/_agent_examples_modal.html diff --git a/application/single_app/config.py b/application/single_app/config.py index 979fbe22..ca74ea2c 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.233.166" +VERSION = "0.233.172" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/templates/_agent_config_info.html b/application/single_app/templates/_agent_config_info.html new file mode 100644 index 00000000..a088e8f5 --- /dev/null +++ b/application/single_app/templates/_agent_config_info.html @@ -0,0 +1,299 @@ +{% from '_agent_examples.html' import agent_examples %} + + + + diff --git a/application/single_app/templates/_agent_examples.html b/application/single_app/templates/_agent_examples.html new file mode 100644 index 00000000..4472d115 --- /dev/null +++ b/application/single_app/templates/_agent_examples.html @@ -0,0 +1,74 @@ +{% macro agent_examples(accordion_id='agentExamples', show_copy_buttons=True, show_create_buttons=False) %} +{% set examples = [ + { + "key": "research", + "title": "General Research Agent", + "helper": "Summaries, citations, and policy-safe answers.", + "display_name": "Policy Research Assistant", + "description": "Summarizes internal policy documents with citations and highlights escalations when answers require legal review.", + "additional_settings": "", + "instructions": "You are a research assistant specialized in internal policy documents.\n- Always cite the document title and section when sharing facts.\n- Ask for clarification if the question lacks enough context.\n- Never speculate about topics outside of the shared corpus; respond with \"I don't have that information\".\n- Summaries must include a short bullet list of key takeaways.\n- Escalate to a human if the user requests legal or HR decisions." + }, + { + "key": "data-extraction", + "title": "Data Extraction Agent", + "helper": "Great for SQL or API driven reporting.", + "display_name": "Operations Data Extractor", + "description": "Runs parameterized queries, returns markdown tables, and documents any data quality issues discovered during extraction.", + "additional_settings": "{\"response_format\": \"table\"}", + "instructions": "You extract structured data for analysts.\n- Confirm the filters (date range, region, product) before running any query.\n- Use the provided SQL/OpenAPI actions and include the action name in your explanation.\n- Return answers as markdown tables with a short narrative summary beneath.\n- Highlight potential data quality issues or empty result sets.\n- If a question requires personally identifiable information, refuse and cite policy." + }, + { + "key": "compliance", + "title": "Compliance & Policy Agent", + "helper": "Best for approvals and guardrails.", + "display_name": "Compliance Review Partner", + "description": "Restates each request with the applicable regulation and delivers an Approve/Block/Needs Review verdict with rationale.", + "additional_settings": "{\"requires_review_log\": true}", + "instructions": "You audit requests for compliance.\n- Start by restating the user's request and the applicable regulation.\n- Cross-check every answer with the official compliance handbook.\n- If the policy is ambiguous, collect more details before responding.\n- Provide a final verdict (Approve, Block, Needs Review) with rationale.\n- Escalate immediately if sensitive data or privileged credentials are detected." + } +] %} +
+ {% for example in examples %} + {% set collapse_id = accordion_id ~ '-collapse-' ~ loop.index %} + {% set heading_id = accordion_id ~ '-heading-' ~ loop.index %} + {% set payload = { + 'display_name': example.display_name, + 'description': example.description, + 'instructions': example.instructions, + 'additional_settings': example.additional_settings + } %} +
+

+ +

+
+
+
+
{{ example.helper }}
+
+ {% if show_copy_buttons %} + + {% endif %} + {% if show_create_buttons %} + + {% endif %} +
+
+

Suggested display name: {{ example.display_name }}

+

{{ example.description }}

+
{{ example.instructions }}
+
+
+
+ {% endfor %} +
+{% endmacro %} diff --git a/application/single_app/templates/_agent_examples_modal.html b/application/single_app/templates/_agent_examples_modal.html new file mode 100644 index 00000000..f664e0de --- /dev/null +++ b/application/single_app/templates/_agent_examples_modal.html @@ -0,0 +1,185 @@ +{% from '_agent_examples.html' import agent_examples %} + + + diff --git a/application/single_app/templates/_agent_modal.html b/application/single_app/templates/_agent_modal.html index b22dc789..893f268c 100644 --- a/application/single_app/templates/_agent_modal.html +++ b/application/single_app/templates/_agent_modal.html @@ -2,8 +2,14 @@

Global Agents

- +
+ + +
{% if settings.orchestration_type == "default_agent" %} diff --git a/application/single_app/templates/group_workspaces.html b/application/single_app/templates/group_workspaces.html index fa1a2f29..965b19f0 100644 --- a/application/single_app/templates/group_workspaces.html +++ b/application/single_app/templates/group_workspaces.html @@ -604,7 +604,13 @@
Group Prompts
Group Agents
- +
+ + +
You do not have permission to manage group agents. diff --git a/application/single_app/templates/workspace.html b/application/single_app/templates/workspace.html index 0c7e9a0b..745fe92e 100644 --- a/application/single_app/templates/workspace.html +++ b/application/single_app/templates/workspace.html @@ -497,7 +497,13 @@
Your Prompts
Your Agents
- +
+ + +
From 25c0d616940a4159705cd09f503cff118978fe45 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Mon, 24 Nov 2025 08:56:58 -0600 Subject: [PATCH 04/18] upd pipeline --- .github/workflows/docker_image_publish_nadoyle.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker_image_publish_nadoyle.yml b/.github/workflows/docker_image_publish_nadoyle.yml index aebfe45a..9880743b 100644 --- a/.github/workflows/docker_image_publish_nadoyle.yml +++ b/.github/workflows/docker_image_publish_nadoyle.yml @@ -6,6 +6,7 @@ on: branches: - nadoyle - feature/group-agents-actions + - feature/aifoundryagents workflow_dispatch: From 685622adfa167f1485b38ee933aaadf43cd92688 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Thu, 4 Dec 2025 13:34:16 -0600 Subject: [PATCH 05/18] add agent template gallery and start of ai foundry agents --- application/single_app/app.py | 2 + application/single_app/config.py | 8 +- .../single_app/foundry_agent_runtime.py | 263 +++++++++ .../single_app/functions_agent_payload.py | 154 ++++++ .../single_app/functions_agent_templates.py | 313 +++++++++++ .../single_app/functions_global_agents.py | 30 +- .../single_app/functions_group_agents.py | 5 +- .../single_app/functions_personal_agents.py | 53 +- application/single_app/functions_settings.py | 3 + .../route_backend_agent_templates.py | 178 ++++++ .../single_app/route_backend_agents.py | 82 +-- application/single_app/route_backend_chats.py | 109 +++- .../route_frontend_admin_settings.py | 9 + .../single_app/semantic_kernel_loader.py | 36 +- .../single_app/static/images/custom_logo.png | Bin 11705 -> 11877 bytes .../static/images/custom_logo_dark.png | Bin 13770 -> 13468 bytes .../static/js/admin/admin_agent_templates.js | 515 ++++++++++++++++++ .../static/js/agent_modal_stepper.js | 122 +++++ .../static/js/agent_templates_gallery.js | 278 ++++++++++ .../static/json/schemas/agent.schema.json | 77 ++- .../single_app/templates/_agent_examples.html | 109 ++-- .../templates/_agent_examples_modal.html | 488 ++++++++++++++++- .../single_app/templates/_agent_modal.html | 5 + .../single_app/templates/admin_settings.html | 155 +++++- application/single_app/templates/base.html | 13 + .../test_backend_foundry_agent_payload.py | 95 ++++ 26 files changed, 2907 insertions(+), 195 deletions(-) create mode 100644 application/single_app/foundry_agent_runtime.py create mode 100644 application/single_app/functions_agent_payload.py create mode 100644 application/single_app/functions_agent_templates.py create mode 100644 application/single_app/route_backend_agent_templates.py create mode 100644 application/single_app/static/js/admin/admin_agent_templates.js create mode 100644 application/single_app/static/js/agent_templates_gallery.js create mode 100644 functional_tests/test_backend_foundry_agent_payload.py diff --git a/application/single_app/app.py b/application/single_app/app.py index 6b17e365..ce50aa2a 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -52,6 +52,7 @@ from route_backend_control_center import * from route_backend_plugins import bpap as admin_plugins_bp, bpdp as dynamic_plugins_bp from route_backend_agents import bpa as admin_agents_bp +from route_backend_agent_templates import bp_agent_templates from route_backend_public_workspaces import * from route_backend_public_documents import * from route_backend_public_prompts import * @@ -85,6 +86,7 @@ app.register_blueprint(admin_plugins_bp) app.register_blueprint(dynamic_plugins_bp) app.register_blueprint(admin_agents_bp) +app.register_blueprint(bp_agent_templates) app.register_blueprint(plugin_validation_bp) app.register_blueprint(bp_migration) app.register_blueprint(plugin_logging_bp) diff --git a/application/single_app/config.py b/application/single_app/config.py index ca74ea2c..7d656000 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.233.172" +VERSION = "0.233.176" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') @@ -375,6 +375,12 @@ def get_redis_cache_infrastructure_endpoint(redis_hostname: str) -> str: partition_key=PartitionKey(path="/id") ) +cosmos_agent_templates_container_name = "agent_templates" +cosmos_agent_templates_container = cosmos_database.create_container_if_not_exists( + id=cosmos_agent_templates_container_name, + partition_key=PartitionKey(path="/id") +) + cosmos_agent_facts_container_name = "agent_facts" cosmos_agent_facts_container = cosmos_database.create_container_if_not_exists( id=cosmos_agent_facts_container_name, diff --git a/application/single_app/foundry_agent_runtime.py b/application/single_app/foundry_agent_runtime.py new file mode 100644 index 00000000..47b0de2e --- /dev/null +++ b/application/single_app/foundry_agent_runtime.py @@ -0,0 +1,263 @@ +"""Azure AI Foundry agent execution helpers.""" + +import asyncio +import logging +import os +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Optional + +from azure.identity import AzureAuthorityHosts +from azure.identity.aio import ( # type: ignore + ClientSecretCredential, + DefaultAzureCredential, +) +from semantic_kernel.agents import AzureAIAgent +from semantic_kernel.contents.chat_message_content import ChatMessageContent + +from functions_appinsights import log_event +from functions_debug import debug_print +from functions_keyvault import ( + retrieve_secret_from_key_vault_by_full_name, + validate_secret_name_dynamic, +) + +_logger = logging.getLogger("foundry_agent_runtime") + + +@dataclass +class FoundryAgentInvocationResult: + """Represents the outcome from a Foundry agent run.""" + + message: str + model: Optional[str] + citations: List[Dict[str, Any]] + metadata: Dict[str, Any] + + +class FoundryAgentInvocationError(RuntimeError): + """Raised when the Foundry agent invocation cannot be completed.""" + + +class AzureAIFoundryChatCompletionAgent: + """Lightweight wrapper so Foundry agents behave like SK chat agents.""" + + agent_type = "aifoundry" + + def __init__(self, agent_config: Dict[str, Any], settings: Dict[str, Any]): + self.name = agent_config.get("name") + self.display_name = agent_config.get("display_name") or self.name + self.description = agent_config.get("description", "") + self.id = agent_config.get("id") + self.default_agent = agent_config.get("default_agent", False) + self.is_global = agent_config.get("is_global", False) + self.is_group = agent_config.get("is_group", False) + self.group_id = agent_config.get("group_id") + self.group_name = agent_config.get("group_name") + self.max_completion_tokens = agent_config.get("max_completion_tokens", -1) + self.last_run_citations: List[Dict[str, Any]] = [] + self.last_run_model: Optional[str] = None + self._foundry_settings = ( + (agent_config.get("other_settings") or {}).get("azure_ai_foundry") or {} + ) + self._global_settings = settings or {} + + def invoke( + self, + agent_message_history: Iterable[ChatMessageContent], + metadata: Optional[Dict[str, Any]] = None, + ) -> str: + """Synchronously invoke the Foundry agent and return the final message text.""" + + metadata = metadata or {} + history = list(agent_message_history) + debug_print( + f"[FoundryAgent] Invoking agent '{self.name}' with {len(history)} messages" + ) + + try: + result = asyncio.run( + execute_foundry_agent( + foundry_settings=self._foundry_settings, + global_settings=self._global_settings, + message_history=history, + metadata=metadata, + ) + ) + except RuntimeError: + raise + except Exception as exc: # pragma: no cover - defensive logging + debug_print(f"[FoundryAgent] Invocation error: {exc}") + raise + + self.last_run_citations = result.citations + self.last_run_model = result.model + return result.message + + +async def execute_foundry_agent( + *, + foundry_settings: Dict[str, Any], + global_settings: Dict[str, Any], + message_history: List[ChatMessageContent], + metadata: Dict[str, Any], +) -> FoundryAgentInvocationResult: + """Invoke a Foundry agent using Semantic Kernel's AzureAIAgent abstraction.""" + + agent_id = (foundry_settings.get("agent_id") or "").strip() + if not agent_id: + raise FoundryAgentInvocationError( + "Azure AI Foundry agents require an agent_id in other_settings.azure_ai_foundry." + ) + + endpoint = _resolve_endpoint(foundry_settings, global_settings) + api_version = foundry_settings.get("api_version") or global_settings.get( + "azure_ai_foundry_api_version" + ) + + credential = _build_async_credential(foundry_settings, global_settings) + client = AzureAIAgent.create_client( + credential=credential, + endpoint=endpoint, + api_version=api_version, + ) + + try: + definition = await client.agents.get_agent(agent_id) + azure_agent = AzureAIAgent(client=client, definition=definition) + responses = [] + async for response in azure_agent.invoke( + messages=message_history, + metadata={k: str(v) for k, v in metadata.items() if v is not None}, + ): + responses.append(response) + + if not responses: + raise FoundryAgentInvocationError("Foundry agent returned no messages.") + + last_response = responses[-1] + if last_response.thread is not None: + try: + await last_response.thread.delete() + except Exception as cleanup_error: # pragma: no cover - best effort cleanup + _logger.warning("Failed to delete Foundry thread: %s", cleanup_error) + + message_obj = last_response.message + text = _extract_message_text(message_obj) + citations = _extract_citations(message_obj) + model_name = getattr(definition, "model", None) + if isinstance(model_name, dict): + model_value = model_name.get("id") + else: + model_value = getattr(model_name, "id", None) + + log_event( + "[FoundryAgent] Invocation complete", + extra={ + "agent_id": agent_id, + "endpoint": endpoint, + "model": model_value, + "message_length": len(text or ""), + }, + ) + + return FoundryAgentInvocationResult( + message=text, + model=model_value, + citations=citations, + metadata=message_obj.metadata or {}, + ) + finally: + try: + await client.close() + finally: + await credential.close() + + +def _resolve_endpoint(foundry_settings: Dict[str, Any], global_settings: Dict[str, Any]) -> str: + endpoint = ( + foundry_settings.get("endpoint") + or global_settings.get("azure_ai_foundry_endpoint") + or os.getenv("AZURE_AI_AGENT_ENDPOINT") + ) + if not endpoint: + raise FoundryAgentInvocationError( + "Azure AI Foundry endpoint is not configured. Provide endpoint in the agent's other_settings.azure_ai_foundry or global settings." + ) + return endpoint.rstrip("/") + + +def _build_async_credential( + foundry_settings: Dict[str, Any], + global_settings: Dict[str, Any], +): + authority = ( + foundry_settings.get("authority") + or global_settings.get("azure_ai_foundry_authority") + or _authority_from_cloud(foundry_settings.get("cloud") or global_settings.get("azure_ai_foundry_cloud")) + ) + + tenant_id = foundry_settings.get("tenant_id") or global_settings.get( + "azure_ai_foundry_tenant_id" + ) + client_id = foundry_settings.get("client_id") or global_settings.get( + "azure_ai_foundry_client_id" + ) + client_secret = foundry_settings.get("client_secret") or global_settings.get( + "azure_ai_foundry_client_secret" + ) + + if client_secret: + resolved_secret = _resolve_secret_value(client_secret) + if not tenant_id or not client_id: + raise FoundryAgentInvocationError( + "Foundry service principals require tenant_id and client_id values." + ) + return ClientSecretCredential( + tenant_id=tenant_id, + client_id=client_id, + client_secret=resolved_secret, + authority=authority, + ) + + # Fall back to default chained credentials (managed identity, CLI, etc.) + return DefaultAzureCredential(authority=authority) + + +def _resolve_secret_value(value: str) -> str: + if validate_secret_name_dynamic(value): + resolved = retrieve_secret_from_key_vault_by_full_name(value) + if not resolved: + raise FoundryAgentInvocationError( + f"Unable to resolve Key Vault secret '{value}' for Foundry credentials." + ) + return resolved + return value + + +def _authority_from_cloud(cloud_value: Optional[str]) -> str: + if not cloud_value: + return AzureAuthorityHosts.AZURE_PUBLIC_CLOUD + + normalized = cloud_value.lower() + if normalized in ("usgov", "usgovernment", "gcc"): + return AzureAuthorityHosts.AZURE_GOVERNMENT + return AzureAuthorityHosts.AZURE_PUBLIC_CLOUD + + +def _extract_message_text(message: ChatMessageContent) -> str: + if message.content: + if isinstance(message.content, str): + return message.content + try: + return "".join(str(chunk) for chunk in message.content) + except TypeError: + return str(message.content) + return "" + + +def _extract_citations(message: ChatMessageContent) -> List[Dict[str, Any]]: + metadata = message.metadata or {} + citations = metadata.get("citations") + if isinstance(citations, list): + return [c for c in citations if isinstance(c, dict)] + return [] diff --git a/application/single_app/functions_agent_payload.py b/application/single_app/functions_agent_payload.py new file mode 100644 index 00000000..27b4c704 --- /dev/null +++ b/application/single_app/functions_agent_payload.py @@ -0,0 +1,154 @@ +# functions_agent_payload.py +"""Utility helpers for normalizing agent payloads before validation and storage.""" + +from copy import deepcopy +from typing import Any, Dict, List + +_SUPPORTED_AGENT_TYPES = {"local", "aifoundry"} +_APIM_FIELDS = [ + "azure_agent_apim_gpt_endpoint", + "azure_agent_apim_gpt_subscription_key", + "azure_agent_apim_gpt_deployment", + "azure_agent_apim_gpt_api_version", +] +_TEXT_FIELDS = [ + "name", + "display_name", + "description", + "instructions", + "azure_openai_gpt_endpoint", + "azure_openai_gpt_deployment", + "azure_openai_gpt_api_version", + "azure_agent_apim_gpt_endpoint", + "azure_agent_apim_gpt_deployment", + "azure_agent_apim_gpt_api_version", +] +_STRING_DEFAULT_FIELDS = [ + "azure_openai_gpt_endpoint", + "azure_openai_gpt_key", + "azure_openai_gpt_deployment", + "azure_openai_gpt_api_version", + "azure_agent_apim_gpt_endpoint", + "azure_agent_apim_gpt_subscription_key", + "azure_agent_apim_gpt_deployment", + "azure_agent_apim_gpt_api_version", +] + + +class AgentPayloadError(ValueError): + """Raised when an agent payload violates backend requirements.""" + + +def is_azure_ai_foundry_agent(agent: Dict[str, Any]) -> bool: + """Return True when the agent type is Azure AI Foundry.""" + agent_type = (agent or {}).get("agent_type", "local") + if isinstance(agent_type, str): + return agent_type.strip().lower() == "aifoundry" + return False + + +def _normalize_text_fields(payload: Dict[str, Any]) -> None: + for field in _TEXT_FIELDS: + value = payload.get(field) + if isinstance(value, str): + payload[field] = value.strip() + + +def _coerce_actions(actions: Any) -> List[str]: + if actions is None or actions == "": + return [] + if not isinstance(actions, list): + raise AgentPayloadError("actions_to_load must be an array of strings.") + cleaned: List[str] = [] + for item in actions: + if isinstance(item, str): + trimmed = item.strip() + if trimmed: + cleaned.append(trimmed) + else: + raise AgentPayloadError("actions_to_load entries must be strings.") + return cleaned + + +def _coerce_other_settings(settings: Any) -> Dict[str, Any]: + if settings in (None, ""): + return {} + if not isinstance(settings, dict): + raise AgentPayloadError("other_settings must be an object.") + return settings + + +def _coerce_agent_type(agent_type: Any) -> str: + if isinstance(agent_type, str): + agent_type = agent_type.strip().lower() + else: + agent_type = "local" + if agent_type not in _SUPPORTED_AGENT_TYPES: + return "local" + return agent_type + + +def _coerce_completion_tokens(value: Any) -> int: + if value in (None, "", " "): + return -1 + try: + return int(value) + except (TypeError, ValueError) as exc: + raise AgentPayloadError("max_completion_tokens must be an integer.") from exc + + +def sanitize_agent_payload(agent: Dict[str, Any]) -> Dict[str, Any]: + """Return a sanitized copy of the agent payload or raise AgentPayloadError.""" + if not isinstance(agent, dict): + raise AgentPayloadError("Agent payload must be an object.") + + sanitized = deepcopy(agent) + _normalize_text_fields(sanitized) + + for field in _STRING_DEFAULT_FIELDS: + value = sanitized.get(field) + if value is None: + sanitized[field] = "" + + agent_type = _coerce_agent_type(sanitized.get("agent_type")) + sanitized["agent_type"] = agent_type + + sanitized["other_settings"] = _coerce_other_settings(sanitized.get("other_settings")) + sanitized["actions_to_load"] = _coerce_actions(sanitized.get("actions_to_load")) + sanitized["max_completion_tokens"] = _coerce_completion_tokens( + sanitized.get("max_completion_tokens") + ) + + sanitized["enable_agent_gpt_apim"] = bool( + sanitized.get("enable_agent_gpt_apim", False) + ) + sanitized.setdefault("is_global", False) + sanitized.setdefault("is_group", False) + + if agent_type == "aifoundry": + sanitized["enable_agent_gpt_apim"] = False + for field in _APIM_FIELDS: + sanitized.pop(field, None) + sanitized["actions_to_load"] = [] + + foundry_settings = sanitized["other_settings"].get("azure_ai_foundry") + if not isinstance(foundry_settings, dict): + raise AgentPayloadError( + "Azure AI Foundry agents require other_settings.azure_ai_foundry." + ) + agent_id = str(foundry_settings.get("agent_id", "")).strip() + if not agent_id: + raise AgentPayloadError( + "Azure AI Foundry agents require other_settings.azure_ai_foundry.agent_id." + ) + foundry_settings["agent_id"] = agent_id + sanitized["other_settings"]["azure_ai_foundry"] = foundry_settings + else: + # Remove stale foundry metadata when toggling back to local agents. + azure_foundry = sanitized["other_settings"].get("azure_ai_foundry") + if azure_foundry is not None and not isinstance(azure_foundry, dict): + raise AgentPayloadError("azure_ai_foundry must be an object when provided.") + if azure_foundry: + sanitized["other_settings"].pop("azure_ai_foundry", None) + + return sanitized diff --git a/application/single_app/functions_agent_templates.py b/application/single_app/functions_agent_templates.py new file mode 100644 index 00000000..292e71a5 --- /dev/null +++ b/application/single_app/functions_agent_templates.py @@ -0,0 +1,313 @@ +"""Agent template helper functions. + +This module centralizes CRUD operations for agent templates stored in the +Cosmos DB `agent_templates` container. Templates are surfaced as reusable +starting points inside the agent builder UI. +""" + +from __future__ import annotations + +import json +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional + +from azure.cosmos import exceptions +from flask import current_app + +from config import cosmos_agent_templates_container +from functions_appinsights import log_event + +STATUS_PENDING = "pending" +STATUS_APPROVED = "approved" +STATUS_REJECTED = "rejected" +STATUS_ARCHIVED = "archived" +ALLOWED_STATUSES = {STATUS_PENDING, STATUS_APPROVED, STATUS_REJECTED, STATUS_ARCHIVED} + + +def _utc_now() -> str: + return datetime.utcnow().isoformat() + + +def _slugify(text: str) -> str: + if not text: + return "template" + slug = text.strip().lower() + allowed = "abcdefghijklmnopqrstuvwxyz0123456789-_" + slug = slug.replace(" ", "-") + slug = ''.join(ch for ch in slug if ch in allowed) + slug = slug.strip('-') + return slug or "template" + + +def _normalize_helper_text(description: str, explicit_helper: Optional[str]) -> str: + helper = explicit_helper or description or "" + helper = helper.strip() + if len(helper) <= 140: + return helper + return helper[:137].rstrip() + "..." + + +def _parse_additional_settings(value: Any) -> Dict[str, Any]: + if not value: + return {} + if isinstance(value, dict): + return value + if isinstance(value, str): + trimmed = value.strip() + if not trimmed: + return {} + try: + return json.loads(trimmed) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON for additional_settings: {exc}") from exc + raise ValueError("additional_settings must be a JSON string or object") + + +def _strip_metadata(doc: Dict[str, Any]) -> Dict[str, Any]: + return {k: v for k, v in doc.items() if not k.startswith('_')} + + +def _serialize_additional_settings(raw: Any) -> str: + try: + parsed = _parse_additional_settings(raw) + except ValueError: + return raw if isinstance(raw, str) else "" + if not parsed: + return "" + return json.dumps(parsed, indent=2, sort_keys=True) + + +def _sanitize_template(doc: Dict[str, Any], include_internal: bool = False) -> Dict[str, Any]: + cleaned = _strip_metadata(doc) + cleaned.setdefault('actions_to_load', []) + cleaned['actions_to_load'] = [a for a in cleaned['actions_to_load'] if a] + cleaned.setdefault('tags', []) + cleaned['tags'] = [str(tag)[:64] for tag in cleaned['tags']] + cleaned['helper_text'] = _normalize_helper_text( + cleaned.get('description', ''), + cleaned.get('helper_text') + ) + cleaned['additional_settings'] = _serialize_additional_settings(cleaned.get('additional_settings')) + cleaned.setdefault('status', STATUS_PENDING) + cleaned.setdefault('title', cleaned.get('display_name') or 'Agent Template') + cleaned.setdefault('template_key', _slugify(cleaned['title'])) + + if not include_internal: + for field in ['submission_notes', 'review_notes', 'rejection_reason', 'created_by', 'created_by_email']: + cleaned.pop(field, None) + + return cleaned + + +def validate_template_payload(payload: Dict[str, Any]) -> Optional[str]: + if not isinstance(payload, dict): + return "Template payload must be an object" + if not (payload.get('display_name') or payload.get('title')): + return "Display name is required" + if not payload.get('description'): + return "Description is required" + if not payload.get('instructions'): + return "Instructions are required" + if payload.get('additional_settings'): + try: + _parse_additional_settings(payload['additional_settings']) + except ValueError as exc: + return str(exc) + return None + + +def list_agent_templates(status: Optional[str] = None, include_internal: bool = False) -> List[Dict[str, Any]]: + query = "SELECT * FROM c" + parameters = [] + if status: + query += " WHERE c.status = @status" + parameters.append({"name": "@status", "value": status}) + + try: + items = list( + cosmos_agent_templates_container.query_items( + query=query, + parameters=parameters or None, + enable_cross_partition_query=True, + ) + ) + except Exception as exc: + current_app.logger.error("Failed to list agent templates: %s", exc) + return [] + + sanitized = [_sanitize_template(item, include_internal) for item in items] + sanitized.sort(key=lambda tpl: tpl.get('title', '').lower()) + return sanitized + + +def get_agent_template(template_id: str) -> Optional[Dict[str, Any]]: + try: + doc = cosmos_agent_templates_container.read_item(item=template_id, partition_key=template_id) + return _sanitize_template(doc, include_internal=True) + except exceptions.CosmosResourceNotFoundError: + return None + except Exception as exc: + current_app.logger.error("Failed to fetch agent template %s: %s", template_id, exc) + return None + + +def _base_template_from_payload(payload: Dict[str, Any], user_info: Optional[Dict[str, Any]], auto_approve: bool) -> Dict[str, Any]: + now = _utc_now() + title = payload.get('title') or payload.get('display_name') or 'Agent Template' + helper_text = _normalize_helper_text(payload.get('description', ''), payload.get('helper_text')) + additional_settings = _parse_additional_settings(payload.get('additional_settings')) + tags = payload.get('tags') or [] + tags = [str(tag)[:64] for tag in tags] + + actions = [str(action) for action in (payload.get('actions_to_load') or []) if action] + + template = { + 'id': payload.get('id') or str(uuid.uuid4()), + 'template_key': payload.get('template_key') or f"{_slugify(title)}-{uuid.uuid4().hex[:6]}", + 'title': title, + 'display_name': payload.get('display_name') or title, + 'helper_text': helper_text, + 'description': payload.get('description', ''), + 'instructions': payload.get('instructions', ''), + 'additional_settings': additional_settings, + 'actions_to_load': actions, + 'tags': tags, + 'status': STATUS_APPROVED if auto_approve else STATUS_PENDING, + 'created_at': now, + 'updated_at': now, + 'created_by': user_info.get('userId') if user_info else None, + 'created_by_name': user_info.get('displayName') if user_info else None, + 'created_by_email': user_info.get('email') if user_info else None, + 'submission_notes': payload.get('submission_notes'), + 'source_agent_id': payload.get('source_agent_id'), + 'source_scope': payload.get('source_scope') or 'personal', + 'approved_by': user_info.get('userId') if auto_approve and user_info else None, + 'approved_at': now if auto_approve else None, + 'review_notes': payload.get('review_notes'), + 'rejection_reason': None, + } + return template + + +def create_agent_template(payload: Dict[str, Any], user_info: Optional[Dict[str, Any]], auto_approve: bool = False) -> Dict[str, Any]: + template = _base_template_from_payload(payload, user_info, auto_approve) + try: + cosmos_agent_templates_container.upsert_item(template) + except Exception as exc: + current_app.logger.error("Failed to save agent template: %s", exc) + raise + + log_event( + "Agent template submitted", + extra={ + "template_id": template['id'], + "status": template['status'], + "created_by": template.get('created_by'), + }, + ) + return _sanitize_template(template, include_internal=True) + + +def update_agent_template(template_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: + doc = get_agent_template(template_id) + if not doc: + return None + + mutable_fields = { + 'title', 'display_name', 'helper_text', 'description', 'instructions', + 'additional_settings', 'actions_to_load', 'tags', 'status' + } + payload = {k: v for k, v in updates.items() if k in mutable_fields} + + if 'additional_settings' in payload: + payload['additional_settings'] = _parse_additional_settings(payload['additional_settings']) + else: + payload['additional_settings'] = _parse_additional_settings(doc.get('additional_settings')) + + if 'tags' in payload: + payload['tags'] = [str(tag)[:64] for tag in payload['tags']] + + if 'status' in payload: + status = payload['status'] + if status not in ALLOWED_STATUSES: + raise ValueError("Invalid template status") + else: + payload['status'] = doc.get('status', STATUS_PENDING) + + template = { + **doc, + **payload, + } + template['updated_at'] = _utc_now() + template['additional_settings'] = payload['additional_settings'] + + try: + cosmos_agent_templates_container.upsert_item(template) + except Exception as exc: + current_app.logger.error("Failed to update agent template %s: %s", template_id, exc) + raise + + return _sanitize_template(template, include_internal=True) + + +def approve_agent_template(template_id: str, approver_info: Dict[str, Any], notes: Optional[str] = None) -> Optional[Dict[str, Any]]: + doc = get_agent_template(template_id) + if not doc: + return None + doc['additional_settings'] = _parse_additional_settings(doc.get('additional_settings')) + doc['status'] = STATUS_APPROVED + doc['approved_by'] = approver_info.get('userId') + doc['approved_at'] = _utc_now() + doc['review_notes'] = notes + doc['rejection_reason'] = None + doc['updated_at'] = doc['approved_at'] + + try: + cosmos_agent_templates_container.upsert_item(doc) + except Exception as exc: + current_app.logger.error("Failed to approve agent template %s: %s", template_id, exc) + raise + + log_event( + "Agent template approved", + extra={"template_id": template_id, "approved_by": doc['approved_by']}, + ) + return _sanitize_template(doc, include_internal=True) + + +def reject_agent_template(template_id: str, approver_info: Dict[str, Any], reason: str, notes: Optional[str] = None) -> Optional[Dict[str, Any]]: + doc = get_agent_template(template_id) + if not doc: + return None + doc['additional_settings'] = _parse_additional_settings(doc.get('additional_settings')) + doc['status'] = STATUS_REJECTED + doc['approved_by'] = approver_info.get('userId') + doc['approved_at'] = _utc_now() + doc['review_notes'] = notes + doc['rejection_reason'] = reason + doc['updated_at'] = doc['approved_at'] + + try: + cosmos_agent_templates_container.upsert_item(doc) + except Exception as exc: + current_app.logger.error("Failed to reject agent template %s: %s", template_id, exc) + raise + + log_event( + "Agent template rejected", + extra={"template_id": template_id, "approved_by": doc['approved_by']}, + ) + return _sanitize_template(doc, include_internal=True) + + +def delete_agent_template(template_id: str) -> bool: + try: + cosmos_agent_templates_container.delete_item(item=template_id, partition_key=template_id) + log_event("Agent template deleted", extra={"template_id": template_id}) + return True + except exceptions.CosmosResourceNotFoundError: + return False + except Exception as exc: + current_app.logger.error("Failed to delete agent template %s: %s", template_id, exc) + raise diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py index 720a1b6c..b9fe7cb2 100644 --- a/application/single_app/functions_global_agents.py +++ b/application/single_app/functions_global_agents.py @@ -16,6 +16,7 @@ from config import cosmos_global_agents_container from functions_keyvault import keyvault_agent_save_helper, keyvault_agent_get_helper, keyvault_agent_delete_helper from functions_settings import * +from functions_agent_payload import sanitize_agent_payload, AgentPayloadError def ensure_default_global_agent_exists(): @@ -167,28 +168,25 @@ def save_global_agent(agent_data): dict: Saved agent data or None if failed """ try: - # Ensure required fields user_id = get_current_user_id() - if 'id' not in agent_data: - agent_data['id'] = str(uuid.uuid4()) - # Add metadata - agent_data['is_global'] = True - agent_data['is_group'] = False - agent_data.setdefault('agent_type', 'local') - agent_data['created_at'] = datetime.utcnow().isoformat() - agent_data['updated_at'] = datetime.utcnow().isoformat() + cleaned_agent = sanitize_agent_payload(agent_data) + if 'id' not in cleaned_agent: + cleaned_agent['id'] = str(uuid.uuid4()) + cleaned_agent['is_global'] = True + cleaned_agent['is_group'] = False + cleaned_agent['created_at'] = datetime.utcnow().isoformat() + cleaned_agent['updated_at'] = datetime.utcnow().isoformat() log_event( "Saving global agent.", - extra={"agent_name": agent_data.get('name', 'Unknown')}, + extra={"agent_name": cleaned_agent.get('name', 'Unknown')}, ) - print(f"Saving global agent: {agent_data.get('name', 'Unknown')}") + print(f"Saving global agent: {cleaned_agent.get('name', 'Unknown')}") - # Use the new helper to store sensitive agent keys in Key Vault - agent_data = keyvault_agent_save_helper(agent_data, agent_data['id'], scope="global") - if agent_data.get('max_completion_tokens') is None: - agent_data['max_completion_tokens'] = -1 # Default value + cleaned_agent = keyvault_agent_save_helper(cleaned_agent, cleaned_agent['id'], scope="global") + if cleaned_agent.get('max_completion_tokens') is None: + cleaned_agent['max_completion_tokens'] = -1 # Default value - result = cosmos_global_agents_container.upsert_item(body=agent_data) + result = cosmos_global_agents_container.upsert_item(body=cleaned_agent) log_event( "Global agent saved successfully.", extra={"agent_id": result['id'], "user_id": user_id}, diff --git a/application/single_app/functions_group_agents.py b/application/single_app/functions_group_agents.py index 92880ebc..30933d3d 100644 --- a/application/single_app/functions_group_agents.py +++ b/application/single_app/functions_group_agents.py @@ -16,6 +16,7 @@ keyvault_agent_get_helper, keyvault_agent_save_helper, ) +from functions_agent_payload import sanitize_agent_payload _NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") @@ -64,8 +65,8 @@ def get_group_agent(group_id: str, agent_id: str) -> Optional[Dict[str, Any]]: def save_group_agent(group_id: str, agent_data: Dict[str, Any]) -> Dict[str, Any]: """Create or update a group agent entry.""" - agent_id = agent_data.get("id") or str(uuid.uuid4()) - payload = dict(agent_data) + payload = sanitize_agent_payload(agent_data) + agent_id = payload.get("id") or str(uuid.uuid4()) payload["id"] = agent_id payload["group_id"] = group_id payload["last_updated"] = datetime.utcnow().isoformat() diff --git a/application/single_app/functions_personal_agents.py b/application/single_app/functions_personal_agents.py index 284e2f25..54875cfe 100644 --- a/application/single_app/functions_personal_agents.py +++ b/application/single_app/functions_personal_agents.py @@ -18,6 +18,7 @@ from config import cosmos_personal_agents_container from functions_settings import get_settings, get_user_settings, update_user_settings from functions_keyvault import keyvault_agent_save_helper, keyvault_agent_get_helper, keyvault_agent_delete_helper +from functions_agent_payload import sanitize_agent_payload def get_personal_agents(user_id): """ @@ -104,36 +105,32 @@ def save_personal_agent(user_id, agent_data): dict: Saved agent data with ID """ try: - # Ensure required fields - if 'id' not in agent_data: - agent_data['id'] = str(f"{user_id}_{agent_data.get('name', 'default')}") + cleaned_agent = sanitize_agent_payload(agent_data) + for field in ['name', 'display_name', 'description', 'instructions']: + cleaned_agent.setdefault(field, '') + for field in [ + 'azure_openai_gpt_endpoint', + 'azure_openai_gpt_key', + 'azure_openai_gpt_deployment', + 'azure_openai_gpt_api_version', + 'azure_agent_apim_gpt_endpoint', + 'azure_agent_apim_gpt_subscription_key', + 'azure_agent_apim_gpt_deployment', + 'azure_agent_apim_gpt_api_version' + ]: + cleaned_agent.setdefault(field, '') + if 'id' not in cleaned_agent: + cleaned_agent['id'] = str(f"{user_id}_{cleaned_agent.get('name', 'default')}") - agent_data['user_id'] = user_id - agent_data['last_updated'] = datetime.utcnow().isoformat() + cleaned_agent['user_id'] = user_id + cleaned_agent['last_updated'] = datetime.utcnow().isoformat() + cleaned_agent['is_global'] = False + cleaned_agent['is_group'] = False - # Validate required fields - required_fields = ['name', 'display_name', 'description', 'instructions'] - for field in required_fields: - if field not in agent_data: - agent_data[field] = '' - - # Set defaults for optional fields - agent_data.setdefault('azure_openai_gpt_deployment', '') - agent_data.setdefault('azure_openai_gpt_api_version', '') - agent_data.setdefault('azure_agent_apim_gpt_deployment', '') - agent_data.setdefault('azure_agent_apim_gpt_api_version', '') - agent_data.setdefault('enable_agent_gpt_apim', False) - agent_data.setdefault('actions_to_load', []) - agent_data.setdefault('other_settings', {}) - agent_data['is_global'] = False - agent_data['is_group'] = False - agent_data.setdefault('agent_type', 'local') - - # Store sensitive keys in Key Vault if enabled - agent_data = keyvault_agent_save_helper(agent_data, agent_data.get('id', ''), scope="user") - if agent_data.get('max_completion_tokens') is None: - agent_data['max_completion_tokens'] = -1 - result = cosmos_personal_agents_container.upsert_item(body=agent_data) + cleaned_agent = keyvault_agent_save_helper(cleaned_agent, cleaned_agent.get('id', ''), scope="user") + if cleaned_agent.get('max_completion_tokens') is None: + cleaned_agent['max_completion_tokens'] = -1 + result = cosmos_personal_agents_container.upsert_item(body=cleaned_agent) # Remove Cosmos metadata from response cleaned_result = {k: v for k, v in result.items() if not k.startswith('_')} cleaned_result.setdefault('is_global', False) diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index a3cfc3ef..fd098d7a 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -40,6 +40,9 @@ def get_settings(use_cosmos=False): 'allow_user_plugins': False, 'allow_group_agents': False, 'allow_group_custom_agent_endpoints': False, + 'enable_agent_template_gallery': True, + 'agent_templates_allow_user_submission': True, + 'agent_templates_require_approval': True, 'allow_group_plugins': False, 'id': 'app_settings', # Control Center settings diff --git a/application/single_app/route_backend_agent_templates.py b/application/single_app/route_backend_agent_templates.py new file mode 100644 index 00000000..08277183 --- /dev/null +++ b/application/single_app/route_backend_agent_templates.py @@ -0,0 +1,178 @@ +"""Backend routes for agent template management.""" + +from flask import Blueprint, jsonify, request, session + +from functions_authentication import ( + admin_required, + login_required, + get_current_user_info, +) +from functions_agent_templates import ( + STATUS_APPROVED, + validate_template_payload, + list_agent_templates, + create_agent_template, + update_agent_template, + approve_agent_template, + reject_agent_template, + delete_agent_template, + get_agent_template, +) +from functions_settings import get_settings + +bp_agent_templates = Blueprint('agent_templates', __name__) + + +def _feature_flags(): + settings = get_settings() + enabled = settings.get('enable_agent_template_gallery', False) + allow_submissions = settings.get('agent_templates_allow_user_submission', True) + require_approval = settings.get('agent_templates_require_approval', True) + return enabled, allow_submissions, require_approval, settings + + +def _is_admin() -> bool: + user = session.get('user') or {} + return 'Admin' in (user.get('roles') or []) + + +@bp_agent_templates.route('/api/agent-templates', methods=['GET']) +@login_required +def list_public_agent_templates(): + enabled, _, _, _ = _feature_flags() + if not enabled: + return jsonify({'templates': []}) + templates = list_agent_templates(status=STATUS_APPROVED, include_internal=False) + return jsonify({'templates': templates}) + + +@bp_agent_templates.route('/api/agent-templates', methods=['POST']) +@login_required +def submit_agent_template(): + enabled, allow_submissions, require_approval, settings = _feature_flags() + if not enabled: + return jsonify({'error': 'Agent template gallery is disabled.'}), 403 + if not settings.get('allow_user_agents') and not _is_admin(): + return jsonify({'error': 'Agent creation is disabled for your workspace.'}), 403 + if not allow_submissions and not _is_admin(): + return jsonify({'error': 'Template submissions are disabled for users.'}), 403 + + data = request.get_json(silent=True) or {} + payload = data.get('template') or data + validation_error = validate_template_payload(payload) + if validation_error: + return jsonify({'error': validation_error}), 400 + + is_admin_user = _is_admin() + payload['source_agent_id'] = payload.get('source_agent_id') or data.get('source_agent_id') + submission_scope = ( + payload.get('source_scope') + or data.get('source_scope') + or ('global' if is_admin_user else 'personal') + ) + submission_scope = str(submission_scope).lower() + payload['source_scope'] = submission_scope + + admin_context_submission = is_admin_user and submission_scope == 'global' + auto_approve = admin_context_submission or not require_approval + + try: + template = create_agent_template(payload, get_current_user_info(), auto_approve=auto_approve) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception: + return jsonify({'error': 'Failed to submit template.'}), 500 + + if not is_admin_user: + for field in ('submission_notes', 'review_notes', 'rejection_reason', 'created_by_email'): + template.pop(field, None) + + status_code = 201 if template.get('status') == STATUS_APPROVED else 202 + return jsonify({'template': template}), status_code + + +@bp_agent_templates.route('/api/admin/agent-templates', methods=['GET']) +@login_required +@admin_required +def admin_list_agent_templates(): + status = request.args.get('status') + if status == 'all': + status = None + templates = list_agent_templates(status=status, include_internal=True) + return jsonify({'templates': templates}) + + +@bp_agent_templates.route('/api/admin/agent-templates/', methods=['GET']) +@login_required +@admin_required +def admin_get_agent_template(template_id): + template = get_agent_template(template_id) + if not template: + return jsonify({'error': 'Template not found.'}), 404 + return jsonify({'template': template}) + + +@bp_agent_templates.route('/api/admin/agent-templates/', methods=['PATCH']) +@login_required +@admin_required +def admin_update_agent_template(template_id): + payload = request.get_json(silent=True) or {} + try: + template = update_agent_template(template_id, payload) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception: + return jsonify({'error': 'Failed to update template.'}), 500 + + if not template: + return jsonify({'error': 'Template not found.'}), 404 + return jsonify({'template': template}) + + +@bp_agent_templates.route('/api/admin/agent-templates//approve', methods=['POST']) +@login_required +@admin_required +def admin_approve_agent_template(template_id): + data = request.get_json(silent=True) or {} + notes = data.get('notes') + try: + template = approve_agent_template(template_id, get_current_user_info(), notes) + except Exception: + return jsonify({'error': 'Failed to approve template.'}), 500 + + if not template: + return jsonify({'error': 'Template not found.'}), 404 + return jsonify({'template': template}) + + +@bp_agent_templates.route('/api/admin/agent-templates//reject', methods=['POST']) +@login_required +@admin_required +def admin_reject_agent_template(template_id): + data = request.get_json(silent=True) or {} + reason = (data.get('reason') or '').strip() + if not reason: + return jsonify({'error': 'A rejection reason is required.'}), 400 + notes = data.get('notes') + try: + template = reject_agent_template(template_id, get_current_user_info(), reason, notes) + except Exception: + return jsonify({'error': 'Failed to reject template.'}), 500 + + if not template: + return jsonify({'error': 'Template not found.'}), 404 + return jsonify({'template': template}) + + +@bp_agent_templates.route('/api/admin/agent-templates/', methods=['DELETE']) +@login_required +@admin_required +def admin_delete_agent_template(template_id): + try: + deleted = delete_agent_template(template_id) + except Exception: + return jsonify({'error': 'Failed to delete template.'}), 500 + + if not deleted: + return jsonify({'error': 'Template not found.'}), 404 + return jsonify({'success': True}) diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index d2e812b1..fba62be9 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -10,6 +10,7 @@ from functions_global_agents import get_global_agents, save_global_agent, delete_global_agent from functions_personal_agents import get_personal_agents, ensure_migration_complete, save_personal_agent, delete_personal_agent from functions_group import require_active_group, assert_group_role +from functions_agent_payload import sanitize_agent_payload, AgentPayloadError from functions_group_agents import ( get_group_agents, get_group_agent, @@ -110,15 +111,16 @@ def set_user_agents(): for agent in agents: if agent.get('is_global', False): continue # Skip global agents - agent['is_global'] = False # Ensure user agents are not global - agent['is_group'] = False - # --- Require at least one deployment field --- - #if not (agent.get('azure_openai_gpt_deployment') or agent.get('azure_agent_apim_gpt_deployment')): - # return jsonify({'error': f'Agent "{agent.get("name", "(unnamed)")}" must have either azure_openai_gpt_deployment or azure_agent_apim_gpt_deployment set.'}), 400 - validation_error = validate_agent(agent) + try: + cleaned_agent = sanitize_agent_payload(agent) + except AgentPayloadError as exc: + return jsonify({'error': str(exc)}), 400 + cleaned_agent['is_global'] = False + cleaned_agent['is_group'] = False + validation_error = validate_agent(cleaned_agent) if validation_error: return jsonify({'error': f'Agent validation failed: {validation_error}'}), 400 - filtered_agents.append(agent) + filtered_agents.append(cleaned_agent) # Enforce global agent only if per_user_semantic_kernel is False per_user_semantic_kernel = settings.get('per_user_semantic_kernel', False) @@ -257,14 +259,15 @@ def create_group_agent_route(): payload = request.get_json(silent=True) or {} try: validate_group_agent_payload(payload, partial=False) - except ValueError as exc: + cleaned_payload = sanitize_agent_payload(payload) + except (ValueError, AgentPayloadError) as exc: return jsonify({'error': str(exc)}), 400 for key in ('group_id', 'last_updated', 'is_global', 'is_group'): - payload.pop(key, None) + cleaned_payload.pop(key, None) try: - saved = save_group_agent(active_group, payload) + saved = save_group_agent(active_group, cleaned_payload) except Exception as exc: current_app.logger.error('Failed to save group agent: %s', exc) return jsonify({'error': 'Unable to save agent'}), 500 @@ -312,7 +315,12 @@ def update_group_agent_route(agent_id): return jsonify({'error': str(exc)}), 400 try: - saved = save_group_agent(active_group, merged) + cleaned_payload = sanitize_agent_payload(merged) + except AgentPayloadError as exc: + return jsonify({'error': str(exc)}), 400 + + try: + saved = save_group_agent(active_group, cleaned_payload) except Exception as exc: current_app.logger.error('Failed to update group agent %s: %s', agent_id, exc) return jsonify({'error': 'Unable to update agent'}), 500 @@ -465,26 +473,31 @@ def add_agent(): try: agents = get_global_agents() new_agent = request.json.copy() if hasattr(request.json, 'copy') else dict(request.json) - new_agent['is_global'] = True - new_agent['is_group'] = False - validation_error = validate_agent(new_agent) + try: + cleaned_agent = sanitize_agent_payload(new_agent) + except AgentPayloadError as exc: + log_event("Add agent failed: payload error", level=logging.WARNING, extra={"action": "add", "error": str(exc)}) + return jsonify({'error': str(exc)}), 400 + cleaned_agent['is_global'] = True + cleaned_agent['is_group'] = False + validation_error = validate_agent(cleaned_agent) if validation_error: - log_event("Add agent failed: validation error", level=logging.WARNING, extra={"action": "add", "agent": new_agent, "error": validation_error}) + log_event("Add agent failed: validation error", level=logging.WARNING, extra={"action": "add", "agent": cleaned_agent, "error": validation_error}) return jsonify({'error': validation_error}), 400 # Prevent duplicate names (case-insensitive) - if any(a['name'].lower() == new_agent['name'].lower() for a in agents): - log_event("Add agent failed: duplicate name", level=logging.WARNING, extra={"action": "add", "agent": new_agent}) + if any(a['name'].lower() == cleaned_agent['name'].lower() for a in agents): + log_event("Add agent failed: duplicate name", level=logging.WARNING, extra={"action": "add", "agent": cleaned_agent}) return jsonify({'error': 'Agent with this name already exists.'}), 400 # Assign a new GUID as id unless this is the default agent (which should have a static GUID) - if not new_agent.get('default_agent', False): - new_agent['id'] = str(uuid.uuid4()) + if not cleaned_agent.get('default_agent', False): + cleaned_agent['id'] = str(uuid.uuid4()) else: # If default_agent, ensure the static GUID is present (do not overwrite if already set) - if not new_agent.get('id'): - new_agent['id'] = '15b0c92a-741d-42ff-ba0b-367c7ee0c848' + if not cleaned_agent.get('id'): + cleaned_agent['id'] = '15b0c92a-741d-42ff-ba0b-367c7ee0c848' # Save to global agents container - result = save_global_agent(new_agent) + result = save_global_agent(cleaned_agent) if not result: return jsonify({'error': 'Failed to save agent.'}), 500 @@ -498,7 +511,7 @@ def add_agent(): if not found: return jsonify({'error': 'There must be at least one agent matching the global_selected_agent.'}), 400 - log_event("Agent added", extra={"action": "add", "agent": {k: v for k, v in new_agent.items() if k != 'id'}, "user": str(get_current_user_id())}) + log_event("Agent added", extra={"action": "add", "agent": {k: v for k, v in cleaned_agent.items() if k != 'id'}, "user": str(get_current_user_id())}) # --- HOT RELOAD TRIGGER --- setattr(builtins, "kernel_reload_needed", True) return jsonify({'success': True}) @@ -575,15 +588,20 @@ def edit_agent(agent_name): try: agents = get_global_agents() updated_agent = request.json.copy() if hasattr(request.json, 'copy') else dict(request.json) - updated_agent['is_global'] = True - updated_agent['is_group'] = False - validation_error = validate_agent(updated_agent) + try: + cleaned_agent = sanitize_agent_payload(updated_agent) + except AgentPayloadError as exc: + log_event("Edit agent failed: payload error", level=logging.WARNING, extra={"action": "edit", "agent_name": agent_name, "error": str(exc)}) + return jsonify({'error': str(exc)}), 400 + cleaned_agent['is_global'] = True + cleaned_agent['is_group'] = False + validation_error = validate_agent(cleaned_agent) if validation_error: - log_event("Edit agent failed: validation error", level=logging.WARNING, extra={"action": "edit", "agent": updated_agent, "error": validation_error}) + log_event("Edit agent failed: validation error", level=logging.WARNING, extra={"action": "edit", "agent": cleaned_agent, "error": validation_error}) return jsonify({'error': validation_error}), 400 # --- Require at least one deployment field --- - if not (updated_agent.get('azure_openai_gpt_deployment') or updated_agent.get('azure_agent_apim_gpt_deployment')): - log_event("Edit agent failed: missing deployment field", level=logging.WARNING, extra={"action": "edit", "agent": updated_agent}) + if not (cleaned_agent.get('azure_openai_gpt_deployment') or cleaned_agent.get('azure_agent_apim_gpt_deployment')): + log_event("Edit agent failed: missing deployment field", level=logging.WARNING, extra={"action": "edit", "agent": cleaned_agent}) return jsonify({'error': 'Agent must have either azure_openai_gpt_deployment or azure_agent_apim_gpt_deployment set.'}), 400 # Find the agent to update @@ -591,7 +609,7 @@ def edit_agent(agent_name): for a in agents: if a['name'] == agent_name: # Preserve the existing id - updated_agent['id'] = a.get('id') + cleaned_agent['id'] = a.get('id') agent_found = True break @@ -600,7 +618,7 @@ def edit_agent(agent_name): return jsonify({'error': 'Agent not found.'}), 404 # Save the updated agent - result = save_global_agent(updated_agent) + result = save_global_agent(cleaned_agent) if not result: return jsonify({'error': 'Failed to save agent.'}), 500 @@ -618,7 +636,7 @@ def edit_agent(agent_name): f"Agent {agent_name} edited", extra={ "action": "edit", - "agent": {k: v for k, v in updated_agent.items() if k != 'id'}, + "agent": {k: v for k, v in cleaned_agent.items() if k != 'id'}, "user": str(get_current_user_id()), } ) diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index c2d37a4b..973e88be 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -1561,14 +1561,6 @@ def make_json_serializable(obj): } ) - # print(f"[Enhanced Agent Citations] Agent used: {agent_used}") - # print(f"[Enhanced Agent Citations] Extracted {len(detailed_citations)} detailed plugin invocations") - # for citation in detailed_citations: - # print(f"[Enhanced Agent Citations] - Plugin: {citation['plugin_name']}, Function: {citation['function_name']}") - # print(f" Parameters: {citation['function_arguments']}") - # print(f" Result: {citation['function_result']}") - # print(f" Duration: {citation['duration_ms']}ms, Success: {citation['success']}") - # Store detailed citations globally to be accessed by the calling function agent_citations_list.extend(detailed_citations) @@ -1588,12 +1580,101 @@ def agent_error(e): level=logging.ERROR, exceptionTraceback=True ) - fallback_steps.append({ - 'name': 'agent', - 'func': invoke_selected_agent, - 'on_success': agent_success, - 'on_error': agent_error - }) + + selected_agent_type = getattr(selected_agent, 'agent_type', 'local') or 'local' + if isinstance(selected_agent_type, str): + selected_agent_type = selected_agent_type.lower() + + if selected_agent_type == 'aifoundry': + def invoke_foundry_agent(): + foundry_metadata = { + 'conversation_id': conversation_id, + 'user_id': user_id, + 'message_id': user_message_id, + 'chat_type': chat_type, + 'document_scope': document_scope, + 'group_id': active_group_id if chat_type == 'group' else None, + 'hybrid_search_enabled': hybrid_search_enabled, + 'selected_document_id': selected_document_id, + 'search_query': search_query, + } + return selected_agent.invoke( + agent_message_history, + metadata={k: v for k, v in foundry_metadata.items() if v is not None} + ) + + def foundry_agent_success(result): + msg = str(result) + notice = None + agent_used = getattr(selected_agent, 'name', 'Azure AI Foundry Agent') + actual_model_deployment = ( + getattr(selected_agent, 'last_run_model', None) + or getattr(selected_agent, 'deployment_name', None) + or agent_used + ) + + foundry_citations = getattr(selected_agent, 'last_run_citations', []) or [] + if foundry_citations: + for citation in foundry_citations: + try: + serializable = json.loads(json.dumps(citation, default=str)) + except (TypeError, ValueError): + serializable = {'value': str(citation)} + agent_citations_list.append({ + 'tool_name': agent_used, + 'function_name': 'azure_ai_foundry_citation', + 'plugin_name': 'azure_ai_foundry', + 'function_arguments': serializable, + 'function_result': serializable, + 'timestamp': datetime.utcnow().isoformat(), + 'success': True + }) + + if enable_multi_agent_orchestration and not per_user_semantic_kernel: + notice = ( + "[SK Fallback]: The AI assistant is running in single agent fallback mode. " + "Some advanced features may not be available. " + "Please contact your administrator to configure Semantic Kernel for richer responses." + ) + + log_event( + f"[Foundry Agent] Invocation complete for {agent_used}", + extra={ + 'conversation_id': conversation_id, + 'user_id': user_id, + 'agent_id': getattr(selected_agent, 'id', None), + 'model_used': actual_model_deployment, + 'citation_count': len(foundry_citations), + } + ) + + return (msg, actual_model_deployment, 'agent', notice) + + def foundry_agent_error(e): + log_event( + f"Error during Azure AI Foundry agent invocation: {str(e)}", + extra={ + 'conversation_id': conversation_id, + 'user_id': user_id, + 'agent_id': getattr(selected_agent, 'id', None) + }, + level=logging.ERROR, + exceptionTraceback=True + ) + + fallback_steps.append({ + 'name': 'foundry_agent', + 'func': invoke_foundry_agent, + 'on_success': foundry_agent_success, + 'on_error': foundry_agent_error + }) + else: + fallback_steps.append({ + 'name': 'agent', + 'func': invoke_selected_agent, + 'on_success': agent_success, + 'on_error': agent_error + }) if kernel: def invoke_kernel(): diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 1b4d1af4..28611ea3 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -147,6 +147,12 @@ def admin_settings(): settings['allow_group_custom_agent_endpoints'] = False if 'allow_group_plugins' not in settings: settings['allow_group_plugins'] = False + if 'enable_agent_template_gallery' not in settings: + settings['enable_agent_template_gallery'] = True + if 'agent_templates_allow_user_submission' not in settings: + settings['agent_templates_allow_user_submission'] = True + if 'agent_templates_require_approval' not in settings: + settings['agent_templates_require_approval'] = True # --- Add defaults for classification banner --- if 'classification_banner_enabled' not in settings: @@ -497,6 +503,9 @@ def is_valid_url(url): 'enable_swagger': form_data.get('enable_swagger') == 'on', 'enable_semantic_kernel': form_data.get('enable_semantic_kernel') == 'on', 'per_user_semantic_kernel': form_data.get('per_user_semantic_kernel') == 'on', + 'enable_agent_template_gallery': form_data.get('enable_agent_template_gallery') == 'on', + 'agent_templates_allow_user_submission': form_data.get('agent_templates_allow_user_submission') == 'on', + 'agent_templates_require_approval': form_data.get('agent_templates_require_approval') == 'on', # GPT (Direct & APIM) 'enable_gpt_apim': form_data.get('enable_gpt_apim') == 'on', diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 2d484e71..dc0f9fbc 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -20,6 +20,7 @@ from semantic_kernel_plugins.embedding_model_plugin import EmbeddingModelPlugin from semantic_kernel_plugins.fact_memory_plugin import FactMemoryPlugin from functions_settings import get_settings, get_user_settings +from foundry_agent_runtime import AzureAIFoundryChatCompletionAgent from functions_appinsights import log_event, get_appinsights_logger from functions_authentication import get_current_user_id from semantic_kernel_plugins.plugin_health_checker import PluginHealthChecker, PluginErrorRecovery @@ -106,6 +107,7 @@ def resolve_agent_config(agent, settings): debug_print(f"[SK Loader] Agent is_group flag: {agent.get('is_group')}") agent_type = (agent.get('agent_type') or 'local').lower() agent['agent_type'] = agent_type + other_settings = agent.get("other_settings", {}) or {} gpt_model_obj = settings.get('gpt_model', {}) selected_model = gpt_model_obj.get('selected', [{}])[0] if gpt_model_obj.get('selected') else {} @@ -231,6 +233,22 @@ def merge_fields(primary, fallback): return tuple(p if p not in [None, ""] else f for p, f in zip(primary, fallback)) # If per-user mode is not enabled, ignore all user/agent-specific config fields + if agent_type == "aifoundry": + return { + "name": agent.get("name"), + "display_name": agent.get("display_name", agent.get("name")), + "description": agent.get("description", ""), + "id": agent.get("id", ""), + "default_agent": agent.get("default_agent", False), + "is_global": agent.get("is_global", False), + "is_group": agent.get("is_group", False), + "group_id": agent.get("group_id"), + "group_name": agent.get("group_name"), + "agent_type": "aifoundry", + "other_settings": other_settings, + "max_completion_tokens": agent.get("max_completion_tokens", -1), + } + if not per_user_enabled: try: if global_apim_enabled: @@ -258,7 +276,8 @@ def merge_fields(primary, fallback): "group_name": agent.get("group_name"), "enable_agent_gpt_apim": agent.get("enable_agent_gpt_apim", False), "max_completion_tokens": agent.get("max_completion_tokens", -1), - "agent_type": agent_type or "local" + "agent_type": agent_type or "local", + "other_settings": other_settings, } except Exception as e: log_event(f"[SK Loader] Error resolving agent config: {e}", level=logging.ERROR, exceptionTraceback=True) @@ -317,6 +336,7 @@ def merge_fields(primary, fallback): "enable_agent_gpt_apim": agent.get("enable_agent_gpt_apim", False), # Use this to check if APIM is enabled for the agent "max_completion_tokens": agent.get("max_completion_tokens", -1), # -1 meant use model default determined by the service, 35-trubo is 4096, 4o is 16384, 4.1 is at least 32768 "agent_type": agent_type or "local", + "other_settings": other_settings, } print(f"[SK Loader] Final resolved config for {agent.get('name')}: endpoint={bool(endpoint)}, key={bool(key)}, deployment={deployment}") @@ -722,6 +742,20 @@ def load_single_agent_for_kernel(kernel, agent_cfg, settings, context_obj, redis chat_service = None apim_enabled = settings.get("enable_gpt_apim", False) + if agent_type == "aifoundry": + foundry_agent = AzureAIFoundryChatCompletionAgent(agent_config, settings) + agent_objs[agent_config["name"]] = foundry_agent + log_event( + f"[SK Loader] Registered Foundry agent: {agent_config['name']} ({mode_label})", + { + "agent_name": agent_config["name"], + "agent_id": agent_config.get("id"), + "is_global": agent_config.get("is_global", False), + }, + level=logging.INFO, + ) + return kernel, agent_objs + log_event(f"[SK Loader] Agent config resolved for {agent_cfg.get('name')} - endpoint: {bool(agent_config.get('endpoint'))}, key: {bool(agent_config.get('key'))}, deployment: {agent_config.get('deployment')}, max_completion_tokens: {agent_config.get('max_completion_tokens')}", level=logging.INFO) if AzureChatCompletion and agent_config["endpoint"] and agent_config["key"] and agent_config["deployment"]: diff --git a/application/single_app/static/images/custom_logo.png b/application/single_app/static/images/custom_logo.png index 45a99fd35f8834db8920ea29bd2bfee10fe754d2..ecf6e6521a737af56bcc82321caff1acefb63494 100644 GIT binary patch literal 11877 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 F{sTGwC=37q literal 11705 zcmbVSRa6~Iv>XoZ2Pe23+%-UOcXyW%G`Ks#NpK79?(P!Yf`s56+}-VQKi=p2d27w= z{@T5I?Vg^ls$CJvic+XZ1V{h?pvp*#tG>xcAr>>W>TnOaU_DBI+I)C%)bp z)DoVjSDG5`{x84BCr;~*>b_9INPx|uba42cuEGbiKVs_SN6>^?j;s7*^5Ty()1Si6 zmsAq{v1{tYzgx`qFKQ!1j-o=65^h`CMS^@d4aZIAmc5?i%Al=0>q<()^-kaY3%
txT=*o8&jKB+g$Gzp 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 Kc$KK}xBmg`z(eK$ diff --git a/application/single_app/static/images/custom_logo_dark.png b/application/single_app/static/images/custom_logo_dark.png index b3beb694201dc8b371e45c973895a95e211eab8e..4f28194576a32f4463ff13ac96521f979e739bb0 100644 GIT binary patch literal 13468 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 aj13{1xX1-4^7hveoPvysbhV^u=>Gr|kZNTB literal 13770 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 diff --git a/application/single_app/static/js/admin/admin_agent_templates.js b/application/single_app/static/js/admin/admin_agent_templates.js new file mode 100644 index 00000000..93ba3113 --- /dev/null +++ b/application/single_app/static/js/admin/admin_agent_templates.js @@ -0,0 +1,515 @@ +// admin_agent_templates.js +// Admin UI logic for reviewing, approving, and deleting agent template submissions + +import { showToast } from "../chat/chat-toast.js"; + +const panel = document.getElementById("agent-templates-admin-panel"); +const tableBody = document.getElementById("agent-template-table-body"); +const statusFilters = document.getElementById("agent-template-status-filters"); +const disabledAlert = document.getElementById("agent-templates-disabled-alert"); +const searchInput = document.getElementById("agent-template-search"); +const paginationEl = document.getElementById("agent-template-pagination"); +const paginationSummary = document.getElementById("agent-template-pagination-summary"); +const paginationNav = document.getElementById("agent-template-pagination-nav"); +const modalEl = document.getElementById("agentTemplateReviewModal"); +const approveBtn = document.getElementById("agent-template-approve-btn"); +const rejectBtn = document.getElementById("agent-template-reject-btn"); +const deleteBtn = document.getElementById("agent-template-delete-btn"); +const notesInput = document.getElementById("agent-template-review-notes"); +const rejectReasonInput = document.getElementById("agent-template-reject-reason"); +const errorAlert = document.getElementById("agent-template-review-error"); +const statusBadge = document.getElementById("agent-template-review-status"); +const helperEl = document.getElementById("agent-template-review-helper"); +const descriptionEl = document.getElementById("agent-template-review-description"); +const instructionsEl = document.getElementById("agent-template-review-instructions"); +const actionsWrapper = document.getElementById("agent-template-review-actions-wrapper"); +const actionsList = document.getElementById("agent-template-review-actions"); +const settingsWrapper = document.getElementById("agent-template-review-settings-wrapper"); +const settingsEl = document.getElementById("agent-template-review-settings"); +const tagsContainer = document.getElementById("agent-template-review-tags"); +const subtitleEl = document.getElementById("agent-template-review-subtitle"); +const metaEl = document.getElementById("agent-template-review-meta"); +const titleEl = document.getElementById("agentTemplateReviewModalLabel"); + +let currentFilter = "pending"; +let templates = []; +let selectedTemplate = null; +let reviewModal = null; +let currentPage = 1; +let searchQuery = ""; +const PAGE_SIZE = 10; + +function init() { + if (!panel) { + return; + } + + if (modalEl && window.bootstrap) { + reviewModal = bootstrap.Modal.getOrCreateInstance(modalEl); + } + + if (!window.appSettings?.enable_agent_template_gallery) { + if (disabledAlert) disabledAlert.classList.remove("d-none"); + renderEmptyState("Template gallery is disabled."); + return; + } + + attachFilterHandlers(); + attachTableHandlers(); + attachSearchHandler(); + attachModalHandlers(); + loadTemplatesForFilter(currentFilter); +} + +function attachFilterHandlers() { + if (!statusFilters) { + return; + } + statusFilters.addEventListener("click", (event) => { + const button = event.target.closest("button[data-status]"); + if (!button) { + return; + } + const { status } = button.dataset; + if (!status || status === currentFilter) { + return; + } + currentFilter = status; + statusFilters.querySelectorAll("button").forEach((btn) => btn.classList.remove("active")); + button.classList.add("active"); + currentPage = 1; + loadTemplatesForFilter(currentFilter); + }); +} + +function attachTableHandlers() { + if (!tableBody) { + return; + } + tableBody.addEventListener("click", (event) => { + const reviewBtn = event.target.closest(".agent-template-review-btn"); + if (reviewBtn) { + const templateId = reviewBtn.dataset.templateId; + openReviewModal(templateId); + return; + } + const deleteBtn = event.target.closest(".agent-template-inline-delete"); + if (deleteBtn) { + const templateId = deleteBtn.dataset.templateId; + confirmAndDelete(templateId); + } + }); +} + +function attachModalHandlers() { + if (!approveBtn || !rejectBtn || !deleteBtn) { + return; + } + + approveBtn.addEventListener("click", () => handleApproval()); + rejectBtn.addEventListener("click", () => handleRejection()); + deleteBtn.addEventListener("click", () => { + if (selectedTemplate?.id) { + confirmAndDelete(selectedTemplate.id, true); + } + }); +} + +function attachSearchHandler() { + if (!searchInput) { + return; + } + searchInput.addEventListener("input", (event) => { + searchQuery = event.target.value?.trim().toLowerCase() || ""; + currentPage = 1; + renderTemplates(); + }); +} + +async function loadTemplatesForFilter(status) { + renderLoadingRow(); + try { + const query = status && status !== "all" ? `?status=${encodeURIComponent(status)}` : "?status=all"; + const response = await fetch(`/api/admin/agent-templates${query}`); + if (!response.ok) { + throw new Error("Failed to load templates."); + } + const data = await response.json(); + templates = data.templates || []; + currentPage = 1; + renderTemplates(); + } catch (error) { + console.error("Error loading agent templates", error); + renderEmptyState(error.message || "Unable to load templates."); + } +} + +function renderLoadingRow() { + if (!tableBody) return; + tableBody.innerHTML = ` +
Loading...
+ Loading templates... + `; + setSummaryMessage("Loading templates..."); + renderPaginationControls(0); +} + +function renderEmptyState(message) { + if (!tableBody) return; + tableBody.innerHTML = `${message}`; + setSummaryMessage(message); + renderPaginationControls(0); +} + +function renderTemplates() { + if (!tableBody) { + return; + } + const filtered = getFilteredTemplates(); + if (!filtered.length) { + const emptyMessage = searchQuery ? "No templates match your search." : "No templates found for this filter."; + renderEmptyState(emptyMessage); + return; + } + + const totalItems = filtered.length; + const totalPages = Math.ceil(totalItems / PAGE_SIZE) || 1; + if (currentPage > totalPages) { + currentPage = totalPages; + } + const startIndex = (currentPage - 1) * PAGE_SIZE; + const pageItems = filtered.slice(startIndex, startIndex + PAGE_SIZE); + const endIndex = startIndex + pageItems.length; + + tableBody.innerHTML = ""; + pageItems.forEach((template) => { + const row = document.createElement("tr"); + row.innerHTML = ` + +
${escapeHtml(template.title || template.display_name || "Template")}
+
${escapeHtml(template.helper_text || template.description || "")}
+ + ${renderStatusBadge(template.status)} + +
${escapeHtml(template.created_by_name || 'Unknown')}
+
${escapeHtml(template.created_by_email || '')}
+ + ${formatDate(template.updated_at || template.created_at)} + +
+ + +
+ + `; + tableBody.appendChild(row); + }); + + setSummaryMessage(`Showing ${startIndex + 1}-${endIndex} of ${totalItems} (page ${currentPage} of ${totalPages})`); + renderPaginationControls(totalPages); +} + +function getFilteredTemplates() { + if (!searchQuery) { + return templates; + } + return templates.filter((template) => { + return [ + template.title, + template.display_name, + template.created_by_name, + template.created_by_email + ].some((value) => value && value.toString().toLowerCase().includes(searchQuery)); + }); +} + +function renderStatusBadge(status) { + const normalized = (status || "pending").toLowerCase(); + const variants = { + approved: "success", + rejected: "danger", + archived: "secondary", + pending: "warning", + }; + const badgeClass = variants[normalized] || "secondary"; + return `${normalized}`; +} + +function setSummaryMessage(message = "") { + if (paginationSummary) { + paginationSummary.textContent = message; + } +} + +function renderPaginationControls(totalPages) { + if (!paginationEl) { + return; + } + + if (paginationNav) { + if (totalPages <= 1) { + paginationNav.classList.add("d-none"); + } else { + paginationNav.classList.remove("d-none"); + } + } + + if (totalPages <= 1) { + paginationEl.innerHTML = ""; + return; + } + + const maxButtons = 5; + let startPage = Math.max(1, currentPage - Math.floor(maxButtons / 2)); + let endPage = startPage + maxButtons - 1; + if (endPage > totalPages) { + endPage = totalPages; + startPage = Math.max(1, endPage - maxButtons + 1); + } + + const fragment = document.createDocumentFragment(); + fragment.appendChild(createPageItem("Previous", currentPage - 1, currentPage === 1)); + + for (let page = startPage; page <= endPage; page += 1) { + fragment.appendChild(createPageItem(page, page, false, page === currentPage)); + } + + fragment.appendChild(createPageItem("Next", currentPage + 1, currentPage === totalPages)); + + paginationEl.innerHTML = ""; + paginationEl.appendChild(fragment); +} + +function createPageItem(label, targetPage, disabled, active = false) { + const li = document.createElement("li"); + li.className = "page-item"; + if (disabled) li.classList.add("disabled"); + if (active) li.classList.add("active"); + + const button = document.createElement("button"); + button.type = "button"; + button.className = "page-link"; + button.textContent = label.toString(); + button.disabled = disabled; + button.addEventListener("click", () => { + if (disabled || targetPage === currentPage) { + return; + } + currentPage = Math.min(Math.max(targetPage, 1), Math.ceil(getFilteredTemplates().length / PAGE_SIZE) || 1); + renderTemplates(); + }); + + li.appendChild(button); + return li; +} + +function formatDate(value) { + if (!value) { + return "-"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleString(); +} + +async function openReviewModal(templateId) { + if (!templateId || !reviewModal) { + return; + } + try { + const response = await fetch(`/api/admin/agent-templates/${templateId}`); + if (!response.ok) { + throw new Error('Failed to load template.'); + } + const data = await response.json(); + selectedTemplate = data.template; + populateReviewModal(selectedTemplate); + reviewModal.show(); + } catch (error) { + console.error('Failed to open template modal', error); + showToast(error.message || 'Unable to load template.', 'danger'); + } +} + +function populateReviewModal(template) { + if (!template) { + return; + } + titleEl.textContent = template.title || template.display_name || 'Agent Template'; + helperEl.textContent = template.helper_text || template.description || '-'; + descriptionEl.textContent = template.description || '-'; + instructionsEl.textContent = template.instructions || ''; + notesInput.value = template.review_notes || ''; + rejectReasonInput.value = template.rejection_reason || ''; + updateStatusBadge(template.status); + + const submittedBy = template.created_by_name || 'Unknown submitter'; + const submittedAt = formatDate(template.created_at); + subtitleEl.textContent = `Submitted by ${submittedBy}`; + metaEl.textContent = `Updated ${formatDate(template.updated_at)}`; + + if (Array.isArray(template.actions_to_load) && template.actions_to_load.length) { + actionsWrapper.classList.remove('d-none'); + actionsList.innerHTML = ''; + template.actions_to_load.forEach((action) => { + const badge = document.createElement('span'); + badge.className = 'badge bg-info text-dark me-1 mb-1'; + badge.textContent = action; + actionsList.appendChild(badge); + }); + } else { + actionsWrapper.classList.add('d-none'); + actionsList.innerHTML = ''; + } + + if (template.additional_settings) { + settingsWrapper.classList.remove('d-none'); + settingsEl.textContent = template.additional_settings; + } else { + settingsWrapper.classList.add('d-none'); + settingsEl.textContent = ''; + } + + if (Array.isArray(template.tags) && template.tags.length) { + tagsContainer.classList.remove('d-none'); + tagsContainer.innerHTML = ''; + template.tags.slice(0, 8).forEach((tag) => { + const badge = document.createElement('span'); + badge.className = 'badge bg-secondary-subtle text-secondary-emphasis'; + badge.textContent = tag; + tagsContainer.appendChild(badge); + }); + } else { + tagsContainer.classList.add('d-none'); + tagsContainer.innerHTML = ''; + } + + hideModalError(); +} + +function updateStatusBadge(status) { + const normalized = (status || 'pending').toLowerCase(); + statusBadge.textContent = normalized; + statusBadge.className = 'badge'; + statusBadge.classList.add(`bg-${{ + approved: 'success', + rejected: 'danger', + archived: 'secondary', + pending: 'warning' + }[normalized] || 'secondary'}`); +} + +function hideModalError() { + if (errorAlert) { + errorAlert.classList.add('d-none'); + errorAlert.textContent = ''; + } +} + +function showModalError(message) { + if (!errorAlert) { + showToast(message, 'danger'); + return; + } + errorAlert.classList.remove('d-none'); + errorAlert.textContent = message; +} + +async function handleApproval() { + if (!selectedTemplate?.id) { + return; + } + await submitTemplateDecision(`/api/admin/agent-templates/${selectedTemplate.id}/approve`, { + notes: notesInput.value?.trim() || undefined + }, 'Template approved!'); +} + +async function handleRejection() { + if (!selectedTemplate?.id) { + return; + } + const reason = rejectReasonInput.value?.trim(); + if (!reason) { + showModalError('A rejection reason is required.'); + rejectReasonInput.focus(); + return; + } + await submitTemplateDecision(`/api/admin/agent-templates/${selectedTemplate.id}/reject`, { + reason, + notes: notesInput.value?.trim() || undefined + }, 'Template rejected.'); +} + +async function submitTemplateDecision(url, payload, successMessage) { + try { + setModalButtonsDisabled(true); + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || 'Failed to update template.'); + } + showToast(successMessage, 'success'); + hideModalError(); + reviewModal?.hide(); + loadTemplatesForFilter(currentFilter); + } catch (error) { + console.error('Template decision failed', error); + showModalError(error.message || 'Failed to update template.'); + } finally { + setModalButtonsDisabled(false); + } +} + +function setModalButtonsDisabled(disabled) { + [approveBtn, rejectBtn, deleteBtn].forEach((btn) => { + if (btn) btn.disabled = disabled; + }); +} + +async function confirmAndDelete(templateId, closeModal = false) { + if (!templateId) { + return; + } + if (!confirm('Delete this template? This action cannot be undone.')) { + return; + } + try { + const response = await fetch(`/api/admin/agent-templates/${templateId}`, { + method: 'DELETE' + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || 'Failed to delete template.'); + } + showToast('Template deleted.', 'success'); + if (closeModal) { + reviewModal?.hide(); + } + loadTemplatesForFilter(currentFilter); + } catch (error) { + console.error('Failed to delete template', error); + showToast(error.message || 'Failed to delete template.', 'danger'); + } +} + +function escapeHtml(value) { + const div = document.createElement('div'); + div.textContent = value || ''; + return div.innerHTML; +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} diff --git a/application/single_app/static/js/agent_modal_stepper.js b/application/single_app/static/js/agent_modal_stepper.js index eb573624..b5cdb646 100644 --- a/application/single_app/static/js/agent_modal_stepper.js +++ b/application/single_app/static/js/agent_modal_stepper.js @@ -12,8 +12,13 @@ export class AgentModalStepper { this.originalAgent = null; // Track original state for change detection this.actionsToSelect = null; // Store actions to select when they're loaded this.updateStepIndicatorTimeout = null; // For debouncing step indicator updates + this.templateSubmitButton = document.getElementById('agent-modal-submit-template-btn'); this.bindEvents(); + + if (this.templateSubmitButton) { + this.templateSubmitButton.addEventListener('click', () => this.submitTemplate()); + } } bindEvents() { @@ -129,6 +134,7 @@ export class AgentModalStepper { this.updateStepIndicator(); this.showStep(1); this.updateNavigationButtons(); + this.updateTemplateButtonVisibility(); console.log('Step indicators initialized'); } else { // Modal not ready yet, try again @@ -309,6 +315,7 @@ export class AgentModalStepper { this.showStep(stepNumber); this.updateStepIndicator(); this.updateNavigationButtons(); + this.updateTemplateButtonVisibility(); } showStep(stepNumber) { @@ -440,6 +447,27 @@ export class AgentModalStepper { } } + canSubmitTemplate() { + if (!window.appSettings || !window.appSettings.enable_agent_template_gallery) { + return false; + } + if (this.isAdmin) { + return true; + } + if (window.appSettings.allow_user_agents === false) { + return false; + } + return window.appSettings.agent_templates_allow_user_submission !== false; + } + + updateTemplateButtonVisibility() { + if (!this.templateSubmitButton) { + return; + } + const shouldShow = this.canSubmitTemplate() && this.currentStep === this.maxSteps; + this.templateSubmitButton.classList.toggle('d-none', !shouldShow); + } + validateCurrentStep() { switch (this.currentStep) { case 1: // Basic Info @@ -1395,6 +1423,100 @@ export class AgentModalStepper { window.showToast(`Agent ${this.isEditMode ? 'updated' : 'created'} successfully!`, 'success'); } } + + validateTemplateRequirements() { + const displayName = document.getElementById('agent-display-name'); + const description = document.getElementById('agent-description'); + const instructions = document.getElementById('agent-instructions'); + + if (!displayName || !displayName.value.trim()) { + this.showError('Please add a display name before submitting a template.'); + displayName?.focus(); + return false; + } + + if (!description || !description.value.trim()) { + this.showError('Please add a description before submitting a template.'); + description?.focus(); + return false; + } + + if (!instructions || !instructions.value.trim()) { + this.showError('Instructions are required before submitting a template.'); + instructions?.focus(); + return false; + } + + this.hideError(); + return true; + } + + buildTemplatePayload() { + const displayName = document.getElementById('agent-display-name')?.value?.trim() || ''; + const description = document.getElementById('agent-description')?.value?.trim() || ''; + const instructions = document.getElementById('agent-instructions')?.value || ''; + const additionalSettings = document.getElementById('agent-additional-settings')?.value || ''; + + return { + title: displayName || 'Agent Template', + display_name: displayName || 'Agent Template', + description, + helper_text: description, + instructions, + additional_settings: additionalSettings, + actions_to_load: this.getSelectedActionIds(), + source_agent_id: this.originalAgent?.id, + source_scope: this.isAdmin ? 'global' : 'personal' + }; + } + + async submitTemplate() { + if (!this.canSubmitTemplate()) { + showToast('Template submissions are disabled right now.', 'warning'); + return; + } + + if (!this.validateTemplateRequirements()) { + return; + } + + const button = this.templateSubmitButton; + if (!button) { + return; + } + + const originalHtml = button.innerHTML; + button.disabled = true; + button.innerHTML = 'Submitting...'; + + try { + const payload = { template: this.buildTemplatePayload() }; + const response = await fetch('/api/agent-templates', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || 'Failed to submit agent template.'); + } + + const status = data.template?.status; + const successMessage = (this.isAdmin && status === 'approved') + ? 'Template published to the gallery!' + : 'Template submitted for review.'; + showToast(successMessage, 'success'); + this.hideError(); + } catch (error) { + console.error('Template submission failed:', error); + this.showError(error.message || 'Failed to submit template.'); + showToast(error.message || 'Failed to submit template.', 'error'); + } finally { + button.disabled = false; + button.innerHTML = originalHtml; + } + } } // Global instance will be created contextually by the calling code diff --git a/application/single_app/static/js/agent_templates_gallery.js b/application/single_app/static/js/agent_templates_gallery.js new file mode 100644 index 00000000..428ebf70 --- /dev/null +++ b/application/single_app/static/js/agent_templates_gallery.js @@ -0,0 +1,278 @@ +// agent_templates_gallery.js +// Dynamically renders the agent template gallery within the agent builder + +import { showToast } from "./chat/chat-toast.js"; + +const gallerySelector = ".agent-template-gallery"; +let cachedTemplates = null; +let loadingPromise = null; + +function getGalleryElements(container) { + return { + spinner: container.querySelector(".agent-template-gallery-loading"), + emptyState: container.querySelector(".agent-template-gallery-empty"), + disabledState: container.querySelector(".agent-template-gallery-disabled"), + errorState: container.querySelector(".agent-template-gallery-error"), + errorText: container.querySelector(".agent-template-gallery-error-text"), + accordion: container.querySelector(".accordion"), + }; +} + +async function fetchTemplates() { + if (cachedTemplates) { + return cachedTemplates; + } + if (loadingPromise) { + return loadingPromise; + } + loadingPromise = fetch("/api/agent-templates") + .then(async (response) => { + if (!response.ok) { + throw new Error("Failed to load templates."); + } + const data = await response.json(); + cachedTemplates = data.templates || []; + return cachedTemplates; + }) + .catch((error) => { + cachedTemplates = []; + throw error; + }) + .finally(() => { + loadingPromise = null; + }); + return loadingPromise; +} + +function renderAccordion(accordion, templates, options = {}) { + const accordionId = options.accordionId || "agentTemplates"; + const showCopy = options.showCopy !== "false"; + const showCreate = options.showCreate !== "false"; + + accordion.innerHTML = ""; + + templates.forEach((template, index) => { + const collapseId = `${accordionId}-collapse-${index}`; + const headingId = `${accordionId}-heading-${index}`; + const instructionsId = `${accordionId}-instructions-${index}`; + + const accordionItem = document.createElement("div"); + accordionItem.className = "accordion-item"; + + const header = document.createElement("h2"); + header.className = "accordion-header"; + header.id = headingId; + + const headerButton = document.createElement("button"); + headerButton.className = `accordion-button${index === 0 ? "" : " collapsed"}`; + headerButton.type = "button"; + headerButton.setAttribute("data-bs-toggle", "collapse"); + headerButton.setAttribute("data-bs-target", `#${collapseId}`); + headerButton.textContent = template.title || template.display_name || "Agent Template"; + header.appendChild(headerButton); + + const collapse = document.createElement("div"); + collapse.id = collapseId; + collapse.className = `accordion-collapse collapse${index === 0 ? " show" : ""}`; + collapse.setAttribute("aria-labelledby", headingId); + collapse.setAttribute("data-bs-parent", `#${accordionId}`); + + const body = document.createElement("div"); + body.className = "accordion-body"; + + const headerRow = document.createElement("div"); + headerRow.className = "d-flex flex-wrap justify-content-between align-items-start gap-2 mb-3"; + + const helper = document.createElement("div"); + helper.className = "small text-muted"; + helper.textContent = template.helper_text || template.description || "Reusable agent template"; + headerRow.appendChild(helper); + + const buttonGroup = document.createElement("div"); + buttonGroup.className = "d-flex gap-2 flex-wrap"; + + if (showCopy) { + const copyBtn = document.createElement("button"); + copyBtn.type = "button"; + copyBtn.className = "btn btn-sm btn-outline-secondary"; + copyBtn.innerHTML = ' Copy'; + copyBtn.addEventListener("click", () => copyInstructions(instructionsId)); + buttonGroup.appendChild(copyBtn); + } + + if (showCreate) { + const createBtn = document.createElement("button"); + createBtn.type = "button"; + createBtn.className = "btn btn-sm btn-success agent-example-create-btn"; + createBtn.innerHTML = ' Use Template'; + const payload = { + display_name: template.display_name || template.title || "Agent Template", + description: template.description || template.helper_text || "", + instructions: template.instructions || "", + additional_settings: template.additional_settings || "", + actions_to_load: template.actions_to_load || [], + }; + createBtn.dataset.agentExample = JSON.stringify(payload); + buttonGroup.appendChild(createBtn); + } + + headerRow.appendChild(buttonGroup); + body.appendChild(headerRow); + + const metaList = document.createElement("div"); + metaList.className = "mb-3"; + + const helperLine = document.createElement("p"); + helperLine.className = "mb-1 text-muted small"; + helperLine.innerHTML = `Suggested display name: ${escapeHtml(template.display_name || template.title || "Agent Template")}`; + metaList.appendChild(helperLine); + + if (Array.isArray(template.tags) && template.tags.length) { + const tagList = document.createElement("div"); + tagList.className = "mb-1"; + template.tags.slice(0, 5).forEach((tag) => { + const badge = document.createElement("span"); + badge.className = "badge bg-secondary-subtle text-secondary-emphasis me-1 mb-1"; + badge.textContent = tag; + tagList.appendChild(badge); + }); + metaList.appendChild(tagList); + } + + if (Array.isArray(template.actions_to_load) && template.actions_to_load.length) { + const actionLine = document.createElement("p"); + actionLine.className = "mb-0 text-muted small"; + actionLine.innerHTML = `Recommended actions: ${template.actions_to_load.join(", ")}`; + metaList.appendChild(actionLine); + } + + body.appendChild(metaList); + + const description = document.createElement("p"); + description.className = "mb-3"; + description.textContent = template.description || template.helper_text || "No description provided."; + body.appendChild(description); + + const instructions = document.createElement("pre"); + instructions.className = "bg-dark text-white p-3 rounded"; + instructions.id = instructionsId; + instructions.textContent = template.instructions || ""; + body.appendChild(instructions); + + if (template.additional_settings) { + const advancedBlock = document.createElement("pre"); + advancedBlock.className = "bg-light border rounded p-3 mt-3"; + advancedBlock.textContent = template.additional_settings; + const advancedLabel = document.createElement("p"); + advancedLabel.className = "text-muted small mb-1"; + advancedLabel.textContent = "Additional settings"; + body.appendChild(advancedLabel); + body.appendChild(advancedBlock); + } + + collapse.appendChild(body); + accordionItem.appendChild(header); + accordionItem.appendChild(collapse); + accordion.appendChild(accordionItem); + }); +} + +function escapeHtml(value) { + const div = document.createElement("div"); + div.textContent = value || ""; + return div.innerHTML; +} + +function copyInstructions(instructionsId) { + const target = document.getElementById(instructionsId); + if (!target) { + return; + } + if (typeof window.copyAgentInstructionSample === "function") { + window.copyAgentInstructionSample(instructionsId); + return; + } + const text = target.textContent || ""; + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).then(() => { + showToast("Instructions copied to clipboard", "success"); + }).catch(() => { + fallbackCopyText(text); + }); + } else { + fallbackCopyText(text); + } +} + +function fallbackCopyText(text) { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.top = "-1000px"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + try { + document.execCommand("copy"); + showToast("Instructions copied to clipboard", "success"); + } catch (err) { + console.error("Clipboard copy failed", err); + showToast("Unable to copy instructions", "error"); + } finally { + document.body.removeChild(textarea); + } +} + +async function initializeGallery(container) { + const elements = getGalleryElements(container); + + if (!window.appSettings?.enable_agent_template_gallery) { + if (elements.spinner) elements.spinner.classList.add("d-none"); + if (elements.disabledState) elements.disabledState.classList.remove("d-none"); + return; + } + + try { + const templates = await fetchTemplates(); + if (elements.spinner) elements.spinner.classList.add("d-none"); + + if (!templates.length) { + if (elements.emptyState) elements.emptyState.classList.remove("d-none"); + return; + } + + if (elements.accordion) { + elements.accordion.classList.remove("d-none"); + renderAccordion(elements.accordion, templates, { + accordionId: container.dataset.accordionId, + showCopy: container.dataset.showCopy, + showCreate: container.dataset.showCreate, + }); + } + } catch (error) { + console.error("Failed to render agent templates", error); + if (elements.spinner) elements.spinner.classList.add("d-none"); + if (elements.errorState) { + elements.errorState.classList.remove("d-none"); + if (elements.errorText) { + elements.errorText.textContent = error.message || "Unexpected error"; + } + } + } +} + +function initAgentTemplateGalleries() { + const containers = document.querySelectorAll(gallerySelector); + if (!containers.length) { + return; + } + containers.forEach((container) => { + initializeGallery(container); + }); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initAgentTemplateGalleries); +} else { + initAgentTemplateGalleries(); +} diff --git a/application/single_app/static/json/schemas/agent.schema.json b/application/single_app/static/json/schemas/agent.schema.json index 69652a15..c349658a 100644 --- a/application/single_app/static/json/schemas/agent.schema.json +++ b/application/single_app/static/json/schemas/agent.schema.json @@ -31,16 +31,19 @@ "type": "string" }, "azure_openai_gpt_endpoint": { - "type": "string" + "type": "string", + "description": "Endpoint for Azure OpenAI (local agents) or Azure AI Foundry (foundry agents)." }, "azure_openai_gpt_key": { "type": "string" }, "azure_openai_gpt_deployment": { - "type": "string" + "type": "string", + "description": "Model deployment for local SK agents or Foundry project/workspace identifier for Azure AI agents." }, "azure_openai_gpt_api_version": { - "type": "string" + "type": "string", + "description": "API version for Azure OpenAI (local) or Azure AI Agents (foundry)." }, "azure_agent_apim_gpt_endpoint": { "type": "string" @@ -73,8 +76,8 @@ }, "agent_type": { "type": "string", - "enum": ["local", "aifoundry", "copilot"], - "description": "Type of agent that needs to be instantiated." + "enum": ["local", "aifoundry"], + "description": "Type of agent to instantiate." }, "instructions": { "type": "string" @@ -84,7 +87,7 @@ "items": { "type": "string" } }, "other_settings": { - "type": "object" + "$ref": "#/definitions/OtherSettings" }, "max_completion_tokens": { "type": "integer", @@ -106,7 +109,67 @@ "max_completion_tokens", "agent_type" ], - "title": "Agent" + "title": "Agent", + "allOf": [ + { + "if": { + "properties": { + "agent_type": { "const": "aifoundry" } + } + }, + "then": { + "required": [ + "azure_openai_gpt_endpoint", + "azure_openai_gpt_deployment", + "azure_openai_gpt_api_version", + "other_settings" + ], + "properties": { + "actions_to_load": { + "type": "array", + "maxItems": 0, + "description": "Azure AI Foundry agents manage tools within Azure and must not specify local plugins." + }, + "other_settings": { + "$ref": "#/definitions/FoundrySettingsWrapper" + } + } + } + } + ] + }, + "OtherSettings": { + "type": "object", + "additionalProperties": true, + "properties": { + "azure_ai_foundry": { + "$ref": "#/definitions/AzureAIFoundrySettings" + } + } + }, + "FoundrySettingsWrapper": { + "type": "object", + "required": ["azure_ai_foundry"], + "properties": { + "azure_ai_foundry": { + "$ref": "#/definitions/AzureAIFoundrySettings" + } + } + }, + "AzureAIFoundrySettings": { + "type": "object", + "additionalProperties": true, + "properties": { + "agent_id": { + "type": "string", + "description": "Identifier of the Azure AI Foundry agent to invoke." + }, + "notes": { + "type": "string", + "description": "Optional helper text for administrators managing Foundry agents." + } + }, + "required": ["agent_id"] } } } diff --git a/application/single_app/templates/_agent_examples.html b/application/single_app/templates/_agent_examples.html index 4472d115..7e523704 100644 --- a/application/single_app/templates/_agent_examples.html +++ b/application/single_app/templates/_agent_examples.html @@ -1,74 +1,45 @@ {% macro agent_examples(accordion_id='agentExamples', show_copy_buttons=True, show_create_buttons=False) %} -{% set examples = [ - { - "key": "research", - "title": "General Research Agent", - "helper": "Summaries, citations, and policy-safe answers.", - "display_name": "Policy Research Assistant", - "description": "Summarizes internal policy documents with citations and highlights escalations when answers require legal review.", - "additional_settings": "", - "instructions": "You are a research assistant specialized in internal policy documents.\n- Always cite the document title and section when sharing facts.\n- Ask for clarification if the question lacks enough context.\n- Never speculate about topics outside of the shared corpus; respond with \"I don't have that information\".\n- Summaries must include a short bullet list of key takeaways.\n- Escalate to a human if the user requests legal or HR decisions." - }, - { - "key": "data-extraction", - "title": "Data Extraction Agent", - "helper": "Great for SQL or API driven reporting.", - "display_name": "Operations Data Extractor", - "description": "Runs parameterized queries, returns markdown tables, and documents any data quality issues discovered during extraction.", - "additional_settings": "{\"response_format\": \"table\"}", - "instructions": "You extract structured data for analysts.\n- Confirm the filters (date range, region, product) before running any query.\n- Use the provided SQL/OpenAPI actions and include the action name in your explanation.\n- Return answers as markdown tables with a short narrative summary beneath.\n- Highlight potential data quality issues or empty result sets.\n- If a question requires personally identifiable information, refuse and cite policy." - }, - { - "key": "compliance", - "title": "Compliance & Policy Agent", - "helper": "Best for approvals and guardrails.", - "display_name": "Compliance Review Partner", - "description": "Restates each request with the applicable regulation and delivers an Approve/Block/Needs Review verdict with rationale.", - "additional_settings": "{\"requires_review_log\": true}", - "instructions": "You audit requests for compliance.\n- Start by restating the user's request and the applicable regulation.\n- Cross-check every answer with the official compliance handbook.\n- If the policy is ambiguous, collect more details before responding.\n- Provide a final verdict (Approve, Block, Needs Review) with rationale.\n- Escalate immediately if sensitive data or privileged credentials are detected." - } -] %} -
- {% for example in examples %} - {% set collapse_id = accordion_id ~ '-collapse-' ~ loop.index %} - {% set heading_id = accordion_id ~ '-heading-' ~ loop.index %} - {% set payload = { - 'display_name': example.display_name, - 'description': example.description, - 'instructions': example.instructions, - 'additional_settings': example.additional_settings - } %} -
-

- -

-
-
-
-
{{ example.helper }}
-
- {% if show_copy_buttons %} - - {% endif %} - {% if show_create_buttons %} - - {% endif %} -
-
-

Suggested display name: {{ example.display_name }}

-

{{ example.description }}

-
{{ example.instructions }}
-
+ {% endmacro %} diff --git a/application/single_app/templates/_agent_examples_modal.html b/application/single_app/templates/_agent_examples_modal.html index f664e0de..52f95cdc 100644 --- a/application/single_app/templates/_agent_examples_modal.html +++ b/application/single_app/templates/_agent_examples_modal.html @@ -1,4 +1,3 @@ -{% from '_agent_examples.html' import agent_examples %} + +
+
+
+
+ Agent Template Gallery +
+

Approve community templates before they appear in the gallery.

+
+
+ + + + +
+
+
+
+
+ + + +
+
+
+
+ + + +
+
+
+
+
+ +
+ + +
+
+
+
Matches template titles, display names, and submitter names/emails.
+
+
+
+
+ +
+ Template gallery is disabled. +
Enable it under Global Agent Feature Toggles (and allow submissions) to collect templates.
+
+
+
+
+ + + + + + + + + + + + + + + +
TemplateStatusSubmitted ByUpdatedActions
+
Loading...
+ Loading templates... +
+
+
+
+ +
+
+
@@ -3137,6 +3285,7 @@
Security Considerat {% if settings.enable_semantic_kernel %} + {% endif %} diff --git a/application/single_app/templates/base.html b/application/single_app/templates/base.html index 2696aa09..8bfdc73b 100644 --- a/application/single_app/templates/base.html +++ b/application/single_app/templates/base.html @@ -286,7 +286,20 @@ diff --git a/functional_tests/test_backend_foundry_agent_payload.py b/functional_tests/test_backend_foundry_agent_payload.py new file mode 100644 index 00000000..72c3daa2 --- /dev/null +++ b/functional_tests/test_backend_foundry_agent_payload.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +Functional test for Azure AI Foundry agent payload sanitation. +Version: 0.233.176 +Implemented in: 0.233.176 + +This test ensures that sanitize_agent_payload enforces Foundry-specific backend +constraints (actions_to_load cleared, APIM disabled, agent_id required) and +prevents invalid Foundry payloads from being persisted. +""" + +import os +import sys + +sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'application', 'single_app')) + +from functions_agent_payload import sanitize_agent_payload, AgentPayloadError + + +def test_foundry_agent_actions_and_apim_rules(): + """Azure AI Foundry agents drop plugins and APIM metadata.""" + print("🔍 Testing Foundry agent sanitization rules...") + + payload = { + "id": "123e4567-e89b-12d3-a456-426614174000", + "name": "foundry_agent", + "display_name": "Foundry Agent", + "description": "Test agent", + "instructions": "Be helpful", + "agent_type": "aifoundry", + "actions_to_load": ["pluginA", "pluginB"], + "enable_agent_gpt_apim": True, + "azure_agent_apim_gpt_endpoint": "https://example", + "azure_agent_apim_gpt_subscription_key": "secret", + "azure_agent_apim_gpt_deployment": "deployment", + "azure_agent_apim_gpt_api_version": "2024-06-01", + "azure_openai_gpt_endpoint": "https://aoai.cognitiveservices.azure.com", + "azure_openai_gpt_deployment": "project", + "azure_openai_gpt_api_version": "2024-05-01-preview", + "other_settings": { + "azure_ai_foundry": {"agent_id": " agent-123 "} + }, + "max_completion_tokens": 16384 + } + + cleaned = sanitize_agent_payload(payload) + + assert cleaned['agent_type'] == 'aifoundry' + assert cleaned['actions_to_load'] == [] + assert cleaned['enable_agent_gpt_apim'] is False + assert 'azure_agent_apim_gpt_endpoint' not in cleaned + assert 'azure_agent_apim_gpt_subscription_key' not in cleaned + assert cleaned['other_settings']['azure_ai_foundry']['agent_id'] == 'agent-123' + + print("✅ Foundry agents automatically drop plugins and APIM secrets.") + + +def test_foundry_agent_requires_agent_id(): + """Missing azure_ai_foundry.agent_id should raise AgentPayloadError.""" + print("🔍 Validating Foundry agent_id requirement...") + + payload = { + "id": "123e4567-e89b-12d3-a456-426614174000", + "name": "foundry_agent", + "display_name": "Foundry Agent", + "description": "Test agent", + "instructions": "Be helpful", + "agent_type": "aifoundry", + "actions_to_load": [], + "azure_openai_gpt_endpoint": "https://aoai.cognitiveservices.azure.com", + "azure_openai_gpt_deployment": "project", + "azure_openai_gpt_api_version": "2024-05-01-preview", + "other_settings": {"azure_ai_foundry": {}}, + "max_completion_tokens": 4096 + } + + try: + sanitize_agent_payload(payload) + except AgentPayloadError as exc: + assert 'agent_id' in str(exc) + print("✅ Missing agent_id correctly rejected.") + return + + raise AssertionError("Expected AgentPayloadError for missing agent_id") + +if __name__ == "__main__": + tests = [ + test_foundry_agent_actions_and_apim_rules, + test_foundry_agent_requires_agent_id + ] + results = [] +@@ + success = all(results) + print(f"\n📊 Results: {sum(results)}/{len(tests)} tests passed") + sys.exit(0 if success else 1) From 9b1e83b27ab5fa7d1c47defb8631a5f0c44b628d Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Tue, 13 Jan 2026 07:52:43 -0600 Subject: [PATCH 06/18] push for paul --- application/single_app/config.py | 2 +- application/single_app/functions_settings.py | 18 ++ application/single_app/requirements.txt | 4 +- application/single_app/route_backend_chats.py | 64 +++-- .../route_frontend_admin_settings.py | 28 +- .../static/js/admin/admin_settings.js | 15 +- .../static/js/admin/admin_sidebar_nav.js | 1 + .../static/js/agent_modal_stepper.js | 270 ++++++++++++++++-- .../static/js/chat/chat-messages.js | 3 + .../static/js/plugin_modal_stepper.js | 79 +++-- .../schemas/plugin.definition.schema.json | 20 ++ .../static/json/schemas/plugin.schema.json | 19 +- .../single_app/templates/_agent_modal.html | 57 +++- .../single_app/templates/_sidebar_nav.html | 17 +- .../single_app/templates/admin_settings.html | 111 ++++++- application/single_app/templates/chats.html | 25 +- 16 files changed, 627 insertions(+), 106 deletions(-) diff --git a/application/single_app/config.py b/application/single_app/config.py index 3d3cd031..98a2e1fc 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -89,7 +89,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.233.318" +VERSION = "0.233.326" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index ffa43d39..b6f31d14 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -40,6 +40,9 @@ def get_settings(use_cosmos=False): 'allow_user_plugins': False, 'allow_group_agents': False, 'allow_group_custom_agent_endpoints': False, + 'allow_ai_foundry_agents': False, + 'allow_group_ai_foundry_agents': False, + 'allow_personal_ai_foundry_agents': False, 'enable_agent_template_gallery': True, 'agent_templates_allow_user_submission': True, 'agent_templates_require_approval': True, @@ -214,6 +217,21 @@ def get_settings(use_cosmos=False): 'azure_apim_document_intelligence_endpoint': '', 'azure_apim_document_intelligence_subscription_key': '', + # Web search (via Azure AI Foundry agent) + 'enable_web_search': False, + 'web_search_agent': { + 'agent_type': 'aifoundry', + 'azure_openai_gpt_endpoint': '', + 'azure_openai_gpt_api_version': '', + 'azure_openai_gpt_deployment': '', + 'other_settings': { + 'azure_ai_foundry': { + 'agent_id': '', + 'notes': '' + } + } + }, + # Authentication & Redirect Settings 'enable_front_door': False, 'front_door_url': '', diff --git a/application/single_app/requirements.txt b/application/single_app/requirements.txt index a5467d9a..ecd82c77 100644 --- a/application/single_app/requirements.txt +++ b/application/single_app/requirements.txt @@ -7,7 +7,7 @@ Flask-WTF==1.2.1 gunicorn Werkzeug==3.0.6 requests==2.32.4 -openai==1.67 +openai>=1.98.0,<2.0.0 docx2txt==0.8 Markdown==3.3.4 bleach==6.1.0 @@ -42,7 +42,7 @@ xlrd==2.0.1 pillow==11.1.0 ffmpeg-binaries-compat==1.0.1 ffmpeg-python==0.2.0 -semantic-kernel>=1.32.1 +semantic-kernel>=1.39.0 redis>=5.0,<6.0 pyodbc>=4.0.0 PyMySQL>=1.0.0 diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index 40b860b8..b39ea8b2 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -57,6 +57,7 @@ def chat_api(): user_message = data.get('message', '') conversation_id = data.get('conversation_id') hybrid_search_enabled = data.get('hybrid_search') + web_search_enabled = data.get('web_search_enabled') selected_document_id = data.get('selected_document_id') image_gen_enabled = data.get('image_generation') document_scope = data.get('doc_scope') @@ -174,6 +175,8 @@ def result_requires_message_reload(result: Any) -> bool: # Convert toggles from string -> bool if needed if isinstance(hybrid_search_enabled, str): hybrid_search_enabled = hybrid_search_enabled.lower() == 'true' + if isinstance(web_search_enabled, str): + web_search_enabled = web_search_enabled.lower() == 'true' if isinstance(image_gen_enabled, str): image_gen_enabled = image_gen_enabled.lower() == 'true' @@ -264,7 +267,7 @@ def result_requires_message_reload(result: Any) -> bool: debug_print(f"Error initializing GPT client/model: {e}") # Handle error appropriately - maybe return 500 or default behavior return jsonify({'error': f'Failed to initialize AI model: {str(e)}'}), 500 - + # region 1 - Load or Create Conversation # --------------------------------------------------------------------- # 1) Load or create conversation # --------------------------------------------------------------------- @@ -358,7 +361,7 @@ def result_requires_message_reload(result: Any) -> bool: elif document_scope == 'public': actual_chat_type = 'public' debug_print(f"New conversation - using legacy logic: {actual_chat_type}") - + # region 2 - Append User Message # --------------------------------------------------------------------- # 2) Append the user message to conversation immediately (or use existing for retry) # --------------------------------------------------------------------- @@ -408,7 +411,8 @@ def result_requires_message_reload(result: Any) -> bool: # Button states and selections user_metadata['button_states'] = { 'image_generation': image_gen_enabled, - 'document_search': hybrid_search_enabled + 'document_search': hybrid_search_enabled, + 'web_search': bool(web_search_enabled) } # Document search scope and selections @@ -616,7 +620,7 @@ def result_requires_message_reload(result: Any) -> bool: conversation_item['last_updated'] = datetime.utcnow().isoformat() cosmos_conversations_container.upsert_item(conversation_item) # Update timestamp and potentially title - + # region 3 - Content Safety # --------------------------------------------------------------------- # 3) Check Content Safety (but DO NOT return 403). # If blocked, add a "safety" role message & skip GPT. @@ -722,7 +726,7 @@ def result_requires_message_reload(result: Any) -> bool: debug_print(f"[Content Safety Error] {e}") except Exception as ex: debug_print(f"[Content Safety] Unexpected error: {ex}") - + # region 4 - Augmentation # --------------------------------------------------------------------- # 4) Augmentation (Search, etc.) - Run *before* final history prep # --------------------------------------------------------------------- @@ -1406,6 +1410,11 @@ def result_requires_message_reload(result: Any) -> bool: 'error': user_friendly_message }), status_code + # Web Search placeholder (until Foundry agent is wired) + if web_search_enabled: + perform_web_search() + + # region 5 - FINAL conversation history preparation # --------------------------------------------------------------------- # 5) Prepare FINAL conversation history for GPT (including summarization) # --------------------------------------------------------------------- @@ -1685,6 +1694,7 @@ def result_requires_message_reload(result: Any) -> bool: debug_print(f"Error preparing conversation history: {e}") return jsonify({'error': f'Error preparing conversation history: {str(e)}'}), 500 + # region 6 - Final GPT Call # --------------------------------------------------------------------- # 6) Final GPT Call # --------------------------------------------------------------------- @@ -2388,7 +2398,7 @@ def gpt_error(e): exceptionTraceback=True ) - + # region 7 - Save GPT Response # --------------------------------------------------------------------- # 7) Save GPT response (or error message) # --------------------------------------------------------------------- @@ -2831,7 +2841,8 @@ def generate(): user_metadata['button_states'] = { 'image_generation': False, - 'document_search': hybrid_search_enabled + 'document_search': hybrid_search_enabled, + 'web_search': bool(web_search_enabled) } # Document search scope and selections @@ -3156,16 +3167,15 @@ def generate(): retrieved_content = "\n\n".join(retrieved_texts) system_prompt_search = f"""You are an AI assistant. Use the following retrieved document excerpts to answer the user's question. Cite sources using the format (Source: filename, Page: page number). + Retrieved Excerpts: + {retrieved_content} -Retrieved Excerpts: -{retrieved_content} - -Based *only* on the information provided above, answer the user's query. If the answer isn't in the excerpts, say so. + Based *only* on the information provided above, answer the user's query. If the answer isn't in the excerpts, say so. -Example -User: What is the policy on double dipping? -Assistant: The policy prohibits entities from using federal funds received through one program to apply for additional funds through another program, commonly known as 'double dipping' (Source: PolicyDocument.pdf, Page: 12) -""" + Example + User: What is the policy on double dipping? + Assistant: The policy prohibits entities from using federal funds received through one program to apply for additional funds through another program, commonly known as 'double dipping' (Source: PolicyDocument.pdf, Page: 12) + """ system_messages_for_augmentation.append({ 'role': 'system', @@ -3176,6 +3186,9 @@ def generate(): # Reorder hybrid citations list in descending order based on page_number hybrid_citations_list.sort(key=lambda x: x.get('page_number', 0), reverse=True) + if web_search_enabled: + perform_web_search() + # Update message chat type message_chat_type = None if hybrid_search_enabled and search_results and len(search_results) > 0: @@ -3886,4 +3899,23 @@ def remove_masked_content(content, masked_ranges): if start < end: result = result[:start] + result[end:] - return result \ No newline at end of file + return result + +def perform_web_search(): + # Web Search placeholder (until Foundry agent is wired) + log_event( + "[WebSearch] Placeholder invoked – routing would call Foundry web search agent here", + extra={ + "user_id": user_id, + "conversation_id": conversation_id, + "doc_scope": document_scope, + "group_id": active_group_id, + "public_workspace_id": active_public_workspace_id, + }, + level=logging.INFO, + ) + # Optionally surface a hint to the model/UI + system_messages_for_augmentation.append({ + "role": "system", + "content": "Web search placeholder: request was flagged for web search, but no agent is configured yet." + }) \ No newline at end of file diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index d694c7bb..9a58dcfd 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -245,6 +245,7 @@ def admin_settings(): return render_template( 'admin_settings.html', settings=settings, + app_settings=settings, update_available=update_available, latest_version=latest_version, download_url=download_url @@ -628,11 +629,20 @@ def is_valid_url(url): 'enable_user_feedback': form_data.get('enable_user_feedback') == 'on', 'enable_conversation_archiving': form_data.get('enable_conversation_archiving') == 'on', - # Search (Web Search Direct & APIM) + # Search (Web Search via Azure AI Foundry agent) 'enable_web_search': form_data.get('enable_web_search') == 'on', - 'enable_web_search_apim': form_data.get('enable_web_search_apim') == 'on', - 'azure_apim_web_search_endpoint': form_data.get('azure_apim_web_search_endpoint', '').strip(), - 'azure_apim_web_search_subscription_key': form_data.get('azure_apim_web_search_subscription_key', '').strip(), + 'web_search_agent': { + 'agent_type': 'aifoundry', + 'azure_openai_gpt_endpoint': form_data.get('web_search_foundry_endpoint', '').strip(), + 'azure_openai_gpt_api_version': form_data.get('web_search_foundry_api_version', '').strip(), + 'azure_openai_gpt_deployment': form_data.get('web_search_foundry_deployment', '').strip(), + 'other_settings': { + 'azure_ai_foundry': { + 'agent_id': form_data.get('web_search_foundry_agent_id', '').strip(), + 'notes': form_data.get('web_search_foundry_notes', '').strip() + } + } + }, # Search (AI Search Direct & APIM) 'azure_ai_search_endpoint': form_data.get('azure_ai_search_endpoint', '').strip(), @@ -700,6 +710,16 @@ def is_valid_url(url): del new_settings['semantic_kernel_agents'] if 'semantic_kernel_plugins' in new_settings: del new_settings['semantic_kernel_plugins'] + + # Remove legacy web search keys if present + for legacy_key in [ + 'bing_search_key', + 'enable_web_search_apim', + 'azure_apim_web_search_endpoint', + 'azure_apim_web_search_subscription_key' + ]: + if legacy_key in new_settings: + del new_settings[legacy_key] logo_file = request.files.get('logo_file') if logo_file and allowed_file(logo_file.filename, ALLOWED_EXTENSIONS_IMG): diff --git a/application/single_app/static/js/admin/admin_settings.js b/application/single_app/static/js/admin/admin_settings.js index 2864bd3d..1669dab2 100644 --- a/application/single_app/static/js/admin/admin_settings.js +++ b/application/single_app/static/js/admin/admin_settings.js @@ -1566,18 +1566,11 @@ function setupToggles() { } const enableWebSearch = document.getElementById('enable_web_search'); - if (enableWebSearch) { + const webSearchFoundrySettings = document.getElementById('web_search_foundry_settings'); + if (enableWebSearch && webSearchFoundrySettings) { + webSearchFoundrySettings.style.display = enableWebSearch.checked ? 'block' : 'none'; enableWebSearch.addEventListener('change', function () { - document.getElementById('web_search_settings').style.display = this.checked ? 'block' : 'none'; - markFormAsModified(); - }); - } - - const enableWebSearchApim = document.getElementById('enable_web_search_apim'); - if (enableWebSearchApim) { - enableWebSearchApim.addEventListener('change', function () { - document.getElementById('non_apim_web_search_settings').style.display = this.checked ? 'none' : 'block'; - document.getElementById('apim_web_search_settings').style.display = this.checked ? 'block' : 'none'; + webSearchFoundrySettings.style.display = this.checked ? 'block' : 'none'; markFormAsModified(); }); } diff --git a/application/single_app/static/js/admin/admin_sidebar_nav.js b/application/single_app/static/js/admin/admin_sidebar_nav.js index de71f7b0..cb290ffa 100644 --- a/application/single_app/static/js/admin/admin_sidebar_nav.js +++ b/application/single_app/static/js/admin/admin_sidebar_nav.js @@ -204,6 +204,7 @@ function scrollToSection(sectionId) { 'permissions-section': 'permissions-section', 'conversation-archiving-section': 'conversation-archiving-section', // Search & Extract tab sections + 'web-search-section': 'web-search-foundry-section', 'azure-ai-search-section': 'azure-ai-search-section', 'document-intelligence-section': 'document-intelligence-section', 'multimedia-support-section': 'multimedia-support-section' diff --git a/application/single_app/static/js/agent_modal_stepper.js b/application/single_app/static/js/agent_modal_stepper.js index 70d19011..800751be 100644 --- a/application/single_app/static/js/agent_modal_stepper.js +++ b/application/single_app/static/js/agent_modal_stepper.js @@ -10,10 +10,12 @@ export class AgentModalStepper { this.maxSteps = 6; this.isEditMode = false; this.isAdmin = isAdmin; // Track if this is admin context + this.currentAgentType = 'local'; this.originalAgent = null; // Track original state for change detection this.actionsToSelect = null; // Store actions to select when they're loaded this.updateStepIndicatorTimeout = null; // For debouncing step indicator updates this.templateSubmitButton = document.getElementById('agent-modal-submit-template-btn'); + this.foundryPlaceholderInstructions = 'Placeholder instructions: Azure AI Foundry agent manages its own prompt.'; this.bindEvents(); @@ -29,6 +31,7 @@ export class AgentModalStepper { const saveBtn = document.getElementById('agent-modal-save-btn'); const skipBtn = document.getElementById('agent-modal-skip'); const powerUserToggle = document.getElementById('agent-power-user-toggle'); + const agentTypeRadios = document.querySelectorAll('input[name="agent-type"]'); if (nextBtn) { nextBtn.addEventListener('click', () => this.nextStep()); @@ -45,6 +48,12 @@ export class AgentModalStepper { if (powerUserToggle) { powerUserToggle.addEventListener('change', (e) => this.togglePowerUserMode(e.target.checked)); } + + if (agentTypeRadios && agentTypeRadios.length) { + agentTypeRadios.forEach(r => { + r.addEventListener('change', (e) => this.handleAgentTypeChange(e.target.value)); + }); + } // Set up display name to generated name conversion this.setupNameGeneration(); @@ -75,6 +84,90 @@ export class AgentModalStepper { } } + handleAgentTypeChange(agentType) { + this.currentAgentType = agentType || 'local'; + this.applyAgentTypeVisibility(); + // Clear actions if switching to foundry + if (this.currentAgentType === 'aifoundry') { + this.clearSelectedActions(); + } + this.populateSummary(); + } + + applyAgentTypeVisibility() { + const isFoundry = this.currentAgentType === 'aifoundry'; + const foundryFields = document.getElementById('agent-foundry-fields'); + const modelGroup = document.getElementById('agent-global-model-group'); + const customToggle = document.getElementById('agent-custom-connection-toggle'); + const customFields = document.getElementById('agent-custom-connection-fields'); + const actionsSection = document.getElementById('agent-step-4'); + const actionsDisabled = document.getElementById('agent-actions-disabled'); + const actionsContainer = document.getElementById('agent-actions-container'); + const actionsHeader = actionsSection?.querySelector('.card'); + const summaryActionsSection = document.getElementById('summary-actions-section'); + const instructionsContainer = document.getElementById('agent-instructions-container'); + const instructionsFoundryNote = document.getElementById('agent-instructions-foundry-note'); + const instructionsInput = document.getElementById('agent-instructions'); + + if (foundryFields) foundryFields.classList.toggle('d-none', !isFoundry); + if (modelGroup) modelGroup.classList.toggle('d-none', isFoundry); + if (customToggle) customToggle.classList.toggle('d-none', isFoundry); + if (customFields) customFields.classList.toggle('d-none', isFoundry); + + if (instructionsContainer) instructionsContainer.classList.toggle('d-none', isFoundry); + if (instructionsFoundryNote) instructionsFoundryNote.classList.toggle('d-none', !isFoundry); + if (instructionsInput) { + if (isFoundry) { + instructionsInput.value = this.foundryPlaceholderInstructions; + } + } + + if (actionsSection) { + // Hide interactive actions when foundry + if (actionsDisabled) actionsDisabled.classList.toggle('d-none', !isFoundry); + if (actionsHeader) actionsHeader.classList.toggle('d-none', isFoundry); + if (actionsContainer) actionsContainer.classList.toggle('d-none', isFoundry); + const noActionsMsg = document.getElementById('agent-no-actions-message'); + if (noActionsMsg) noActionsMsg.classList.toggle('d-none', isFoundry); + const selectedSummary = document.getElementById('agent-selected-actions-summary'); + if (selectedSummary) selectedSummary.classList.toggle('d-none', isFoundry); + } + + if (summaryActionsSection) { + summaryActionsSection.classList.toggle('d-none', isFoundry); + } + + // Update helper text + const helper = document.getElementById('agent-type-helper'); + if (helper) { + helper.textContent = isFoundry + ? 'Foundry agents use Azure-managed tools. Actions step is disabled.' + : 'Local agents can attach actions and use SK plugins.'; + } + } + + updateAgentTypeLock() { + const radios = document.querySelectorAll('input[name="agent-type"]'); + if (!radios || !radios.length) { + return; + } + + const shouldDisable = this.isEditMode || this.currentStep > 1; + + radios.forEach(radio => { + radio.disabled = shouldDisable; + const wrapper = radio.closest('.form-check'); + if (wrapper) { + wrapper.classList.toggle('opacity-50', shouldDisable); + } + }); + + const selector = document.getElementById('agent-type-selector'); + if (selector) { + selector.classList.toggle('pe-none', shouldDisable); + } + } + updateReasoningEffortForModel() { const globalModelSelect = document.getElementById('agent-global-model-select'); const reasoningEffortSelect = document.getElementById('agent-reasoning-effort'); @@ -152,6 +245,7 @@ export class AgentModalStepper { showModal(agent = null) { this.isEditMode = !!agent; + this.currentAgentType = (agent && agent.agent_type) || 'local'; // Store original state for change detection this.originalAgent = agent ? JSON.parse(JSON.stringify(agent)) : null; @@ -184,6 +278,9 @@ export class AgentModalStepper { // Ensure generated name is populated for both new and existing agents this.updateGeneratedName(); + this.syncAgentTypeSelector(); + this.applyAgentTypeVisibility(); + this.updateAgentTypeLock(); // Load models for the modal this.loadModelsForModal(); @@ -231,6 +328,14 @@ export class AgentModalStepper { } } + syncAgentTypeSelector() { + const radios = document.querySelectorAll('input[name="agent-type"]'); + if (!radios || !radios.length) return; + radios.forEach(r => { + r.checked = r.value === this.currentAgentType; + }); + } + clearFields() { // Clear all form fields const displayName = document.getElementById('agent-display-name'); @@ -288,6 +393,11 @@ export class AgentModalStepper { customConnection.checked = agentsCommon.shouldEnableCustomConnection(agent); } + // Agent type selection + this.currentAgentType = agent.agent_type || 'local'; + this.syncAgentTypeSelector(); + this.applyAgentTypeVisibility(); + // Use shared function to populate all fields if (agentsCommon && typeof agentsCommon.setAgentModalFields === 'function') { agentsCommon.setAgentModalFields(agent); @@ -333,6 +443,24 @@ export class AgentModalStepper { if (agent.actions_to_load && Array.isArray(agent.actions_to_load)) { this.actionsToSelect = agent.actions_to_load; } + + // Foundry-specific fields + if (agent.agent_type === 'aifoundry') { + const other = agent.other_settings || {}; + const foundry = (other && other.azure_ai_foundry) || {}; + const endpointEl = document.getElementById('agent-foundry-endpoint'); + const apiEl = document.getElementById('agent-foundry-api-version'); + const depEl = document.getElementById('agent-foundry-deployment'); + const idEl = document.getElementById('agent-foundry-agent-id'); + const notesEl = document.getElementById('agent-foundry-notes'); + if (endpointEl) endpointEl.value = agent.azure_openai_gpt_endpoint || ''; + if (apiEl) apiEl.value = agent.azure_openai_gpt_api_version || ''; + if (depEl) depEl.value = agent.azure_openai_gpt_deployment || ''; + if (idEl) idEl.value = foundry.agent_id || ''; + if (notesEl) notesEl.value = foundry.notes || ''; + // ensure actions cleared for UI + this.clearSelectedActions(); + } } nextStep() { @@ -363,7 +491,9 @@ export class AgentModalStepper { skipBtn.innerHTML = `Skipping...`; } try { - await this.loadAvailableActions(); + if (this.currentAgentType !== 'aifoundry') { + await this.loadAvailableActions(); + } this.goToStep(this.maxSteps); } catch (error) { console.error('Error loading actions:', error); @@ -387,6 +517,7 @@ export class AgentModalStepper { this.updateStepIndicator(); this.updateNavigationButtons(); this.updateTemplateButtonVisibility(); + this.updateAgentTypeLock(); } showStep(stepNumber) { @@ -405,22 +536,31 @@ export class AgentModalStepper { } if (stepNumber === 2) { - if (!this.isAdmin) { - const customConnectionToggle = document.getElementById('agent-custom-connection-toggle'); - if (customConnectionToggle) { + const isFoundry = this.currentAgentType === 'aifoundry'; + const customConnectionToggle = document.getElementById('agent-custom-connection-toggle'); + const modelGroup = document.getElementById('agent-global-model-group'); + + if (customConnectionToggle) { + if (isFoundry) { + customConnectionToggle.classList.add('d-none'); + } else if (!this.isAdmin) { const allowUserCustom = appSettings?.allow_user_custom_agent_endpoints; - if (!allowUserCustom) { - customConnectionToggle.classList.add('d-none'); - } else { - customConnectionToggle.classList.remove('d-none'); - } + customConnectionToggle.classList.toggle('d-none', !allowUserCustom); + } else { + customConnectionToggle.classList.remove('d-none'); } } + + if (modelGroup) { + modelGroup.classList.toggle('d-none', isFoundry); + } } // Load actions when reaching step 4 if (stepNumber === 4) { - this.loadAvailableActions(); + if (this.currentAgentType !== 'aifoundry') { + this.loadAvailableActions(); + } } // Populate summary when reaching step 6 @@ -559,20 +699,54 @@ export class AgentModalStepper { break; case 2: // Model & Connection - // Model validation would go here + if (this.currentAgentType === 'aifoundry') { + const endpoint = document.getElementById('agent-foundry-endpoint'); + const apiVersion = document.getElementById('agent-foundry-api-version'); + const deployment = document.getElementById('agent-foundry-deployment'); + const agentId = document.getElementById('agent-foundry-agent-id'); + if (!endpoint || !endpoint.value.trim()) { + this.showError('Azure AI Foundry endpoint is required.'); + endpoint?.focus(); + return false; + } + if (!apiVersion || !apiVersion.value.trim()) { + this.showError('Azure AI Foundry API version is required.'); + apiVersion?.focus(); + return false; + } + if (!deployment || !deployment.value.trim()) { + this.showError('Foundry deployment/project is required.'); + deployment?.focus(); + return false; + } + if (!agentId || !agentId.value.trim()) { + this.showError('Foundry agent ID is required.'); + agentId?.focus(); + return false; + } + } break; case 3: // Instructions const instructions = document.getElementById('agent-instructions'); - if (!instructions || !instructions.value.trim()) { - this.showError('Please provide instructions for the agent.'); - if (instructions) instructions.focus(); - return false; - } + if (this.currentAgentType !== 'aifoundry') { + if (!instructions || !instructions.value.trim()) { + this.showError('Please provide instructions for the agent.'); + if (instructions) instructions.focus(); + return false; + } + } else { + // Ensure placeholder present + if (instructions && !instructions.value.trim()) { + instructions.value = this.foundryPlaceholderInstructions; + } + } break; case 4: // Actions - // Actions validation would go here if needed + if (this.currentAgentType !== 'aifoundry') { + // Actions validation would go here if needed + } break; case 5: // Advanced @@ -676,6 +850,10 @@ export class AgentModalStepper { } getFormModelName() { + if (this.currentAgentType === 'aifoundry') { + const foundryDeployment = document.getElementById('agent-foundry-deployment'); + return foundryDeployment?.value?.trim() || '-'; + } const customConnection = document.getElementById('agent-custom-connection')?.checked || false; let modelName = '-'; if (customConnection) { @@ -699,6 +877,7 @@ export class AgentModalStepper { const displayName = document.getElementById('agent-display-name')?.value || '-'; const generatedName = document.getElementById('agent-name')?.value || '-'; const description = document.getElementById('agent-description')?.value || '-'; + const agentType = this.currentAgentType || 'local'; // Model & Connection const customConnection = document.getElementById('agent-custom-connection')?.checked ? 'Yes' : 'No'; @@ -719,6 +898,11 @@ export class AgentModalStepper { // Update configuration document.getElementById('summary-model').textContent = modelName; document.getElementById('summary-custom-connection').textContent = customConnection; + const typeBadge = document.getElementById('summary-agent-type-badge'); + if (typeBadge) { + typeBadge.textContent = agentType === 'aifoundry' ? 'Azure AI Foundry' : 'Local (Semantic Kernel)'; + typeBadge.className = agentType === 'aifoundry' ? 'badge bg-warning text-dark' : 'badge bg-info'; + } // Update instructions document.getElementById('summary-instructions').textContent = instructions; @@ -733,10 +917,16 @@ export class AgentModalStepper { const actionsListContainer = document.getElementById('summary-actions-list'); const actionsEmptyContainer = document.getElementById('summary-actions-empty'); - if (actionsCount > 0) { + if (this.currentAgentType === 'aifoundry') { + // Hide actions entirely for Foundry + const actionsSection = document.getElementById('summary-actions-section'); + if (actionsSection) actionsSection.style.display = 'none'; + } else if (actionsCount > 0) { // Show actions list, hide empty message actionsListContainer.style.display = 'block'; actionsEmptyContainer.style.display = 'none'; + const actionsSection = document.getElementById('summary-actions-section'); + if (actionsSection) actionsSection.style.display = ''; // Clear existing content actionsListContainer.innerHTML = ''; @@ -779,6 +969,8 @@ export class AgentModalStepper { // Hide actions list, show empty message actionsListContainer.style.display = 'none'; actionsEmptyContainer.style.display = 'block'; + const actionsSection = document.getElementById('summary-actions-section'); + if (actionsSection) actionsSection.style.display = ''; } // Update creation date @@ -1275,8 +1467,12 @@ export class AgentModalStepper { } } - // Add selected actions - agentData.actions_to_load = this.getSelectedActionIds(); + // Add selected actions (skip for Foundry) + if (agentData.agent_type === 'aifoundry') { + agentData.actions_to_load = []; + } else { + agentData.actions_to_load = this.getSelectedActionIds(); + } agentData.is_global = this.isAdmin; // Set based on admin context // Ensure required schema fields are present @@ -1332,6 +1528,9 @@ export class AgentModalStepper { } getAgentFormData() { + const agentTypeInput = document.querySelector('input[name="agent-type"]:checked'); + const selectedAgentType = agentTypeInput ? agentTypeInput.value : 'local'; + const formData = { display_name: document.getElementById('agent-display-name')?.value || '', name: document.getElementById('agent-name')?.value || '', @@ -1342,8 +1541,37 @@ export class AgentModalStepper { other_settings: document.getElementById('agent-additional-settings')?.value || '{}', max_completion_tokens: parseInt(document.getElementById('agent-max-completion-tokens')?.value.trim()) || null, reasoning_effort: document.getElementById('agent-reasoning-effort')?.value || '', - agent_type: 'local' + agent_type: selectedAgentType }; + + if (selectedAgentType === 'aifoundry') { + // Foundry required fields + formData.azure_openai_gpt_endpoint = document.getElementById('agent-foundry-endpoint')?.value?.trim() || ''; + formData.azure_openai_gpt_deployment = document.getElementById('agent-foundry-deployment')?.value?.trim() || ''; + formData.azure_openai_gpt_api_version = document.getElementById('agent-foundry-api-version')?.value?.trim() || ''; + formData.instructions = document.getElementById('agent-instructions')?.value?.trim() || this.foundryPlaceholderInstructions; + + // other_settings for foundry + let otherSettingsObj = {}; + try { + otherSettingsObj = JSON.parse(formData.other_settings || '{}'); + } catch (e) { + otherSettingsObj = {}; + } + otherSettingsObj = otherSettingsObj || {}; + const notesVal = document.getElementById('agent-foundry-notes')?.value || ''; + otherSettingsObj.azure_ai_foundry = { + ...(otherSettingsObj.azure_ai_foundry || {}), + agent_id: document.getElementById('agent-foundry-agent-id')?.value?.trim() || '', + ...(notesVal ? { notes: notesVal } : {}) + }; + formData.other_settings = JSON.stringify(otherSettingsObj); + + // Foundry agents cannot have actions + formData.actions_to_load = []; + formData.enable_agent_gpt_apim = false; + return formData; + } // Handle model and deployment configuration if (formData.custom_connection) { diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index 3ff0f070..55f99fbd 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -1431,6 +1431,8 @@ export function actuallySendMessage(finalMessageToSend) { // Fallback: if group_id is null/empty, use window.activeGroupId const finalGroupId = group_id || window.activeGroupId || null; + const webSearchToggle = document.getElementById("search-web-btn"); + const webSearchEnabled = webSearchToggle ? webSearchToggle.classList.contains("active") : false; // Prepare message data object // Get active public workspace ID from user settings (similar to active_group_id) @@ -1440,6 +1442,7 @@ export function actuallySendMessage(finalMessageToSend) { message: finalMessageToSend, conversation_id: currentConversationId, hybrid_search: hybridSearchEnabled, + web_search_enabled: webSearchEnabled, selected_document_id: selectedDocumentId, classifications: classificationsToSend, image_generation: imageGenEnabled, diff --git a/application/single_app/static/js/plugin_modal_stepper.js b/application/single_app/static/js/plugin_modal_stepper.js index d017f4c2..815db670 100644 --- a/application/single_app/static/js/plugin_modal_stepper.js +++ b/application/single_app/static/js/plugin_modal_stepper.js @@ -16,9 +16,11 @@ export class PluginModalStepper { this.filteredTypes = []; this.originalPlugin = null; // Store original state for change tracking this.pluginSchemaCache = null; // Will hold plugin.schema.json + this.pluginDefinitionCache = {}; // Cache for per-type definition schemas this.additionalSettingsSchemaCache = {}; // Cache for additional settings schemas this.lastAdditionalFieldsType = null; // Track last type to avoid unnecessary redraws - this.defaultAuthTypes = ["key", "identity", "user", "servicePrincipal", "connection_string", "basic", "username_password"]; + this.defaultAuthTypes = ["NoAuth", "key", "identity", "user", "servicePrincipal", "connection_string", "basic", "username_password"]; + this.currentAllowedAuthTypes = null; // Active allowed auth types derived from definition this._loadPluginSchema().then(() => { // Load schema on initialization this._populateGenericAuthTypeDropdown(); // Dynamically populate generic auth type dropdown after schema loads (will be called again after schema loads) @@ -37,33 +39,58 @@ export class PluginModalStepper { } } + getAuthTypeEnumFromSchema() { + const authEnum = this.pluginSchemaCache?.definitions?.AuthType?.enum; + return Array.isArray(authEnum) && authEnum.length ? authEnum : null; + } + + async loadPluginDefinition(type) { + const safeType = this.getSafeType(type); + if (!safeType) return null; + + if (Object.prototype.hasOwnProperty.call(this.pluginDefinitionCache, safeType)) { + return this.pluginDefinitionCache[safeType]; + } + + try { + const res = await fetch(`/static/json/schemas/${safeType}.definition.json`); + if (!res.ok) throw new Error(`Definition fetch failed with status ${res.status}`); + const json = await res.json(); + this.pluginDefinitionCache[safeType] = json; + return json; + } catch (err) { + console.warn(`No plugin definition found for type '${safeType}':`, err.message || err); + this.pluginDefinitionCache[safeType] = null; + return null; + } + } + + async applyDefinitionForSelectedType(type = this.selectedType) { + this.currentAllowedAuthTypes = null; + + if (type) { + const definition = await this.loadPluginDefinition(type); + const allowed = definition?.allowedAuthTypes; + if (Array.isArray(allowed) && allowed.length) { + this.currentAllowedAuthTypes = allowed; + } + } + + this._populateGenericAuthTypeDropdown(); + } + _populateGenericAuthTypeDropdown() { // Only run if dropdown exists const dropdown = document.getElementById('plugin-auth-type-generic'); if (!dropdown) return; - // If schema not loaded, fallback to static options - if (!this.pluginSchemaCache) { - dropdown.innerHTML = ''; - this.defaultAuthTypes.forEach(type => { - const option = document.createElement('option'); - option.value = type; - option.textContent = this.formatAuthType(type); - dropdown.appendChild(option); - }); - return; - } - // Find the enum for generic auth type in the schema - let authTypeEnum = []; - if (this.pluginSchemaCache.properties && this.pluginSchemaCache.properties.authTypeGeneric) { - authTypeEnum = this.pluginSchemaCache.properties.authTypeGeneric.enum || []; - } - // Fallback: if not found, use a default - if (!authTypeEnum.length) { - authTypeEnum = this.defaultAuthTypes; - } + const fullAuthEnum = this.getAuthTypeEnumFromSchema() || this.defaultAuthTypes; + const allowedList = this.currentAllowedAuthTypes && this.currentAllowedAuthTypes.length + ? this.currentAllowedAuthTypes + : fullAuthEnum; + // Clear existing options dropdown.innerHTML = ''; - authTypeEnum.forEach(type => { + allowedList.forEach(type => { const option = document.createElement('option'); option.value = type; option.textContent = this.formatAuthType(type); @@ -137,6 +164,7 @@ export class PluginModalStepper { // Load available types and populate await this.loadAvailableTypes(); + await this.applyDefinitionForSelectedType(this.selectedType); if (this.isEditMode) { this.populateFormFromPlugin(plugin); @@ -301,6 +329,9 @@ export class PluginModalStepper { document.getElementById('plugin-description').value = typeData.description; } + // Apply auth definition overrides for this type + this.applyDefinitionForSelectedType(typeName).catch(err => console.error('Definition apply failed:', err)); + // Pre-configure for step 3 if needed this.showConfigSectionForType(); } @@ -1841,7 +1872,8 @@ export class PluginModalStepper { 'user': 'User', 'servicePrincipal': 'Service Principal', 'connection_string': 'Connection String', - 'basic': 'Basic' + 'basic': 'Basic', + 'NoAuth': 'No Authentication' }; return authTypeMap[authType] || authType; } @@ -2266,6 +2298,7 @@ export class PluginModalStepper { // Clear any type selection this.selectedType = null; + this.currentAllowedAuthTypes = null; // Hide all auth field sections (with safe calls) try { diff --git a/application/single_app/static/json/schemas/plugin.definition.schema.json b/application/single_app/static/json/schemas/plugin.definition.schema.json index e69de29b..92a97e06 100644 --- a/application/single_app/static/json/schemas/plugin.definition.schema.json +++ b/application/single_app/static/json/schemas/plugin.definition.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "plugin.definition.schema.json", + "title": "Plugin Definition", + "description": "Controls plugin creation constraints such as allowed authentication types.", + "type": "object", + "additionalProperties": false, + "properties": { + "allowedAuthTypes": { + "type": "array", + "description": "List of auth types this plugin supports. Values must match auth.type in plugin.schema.json.", + "items": { + "$ref": "plugin.schema.json#/definitions/AuthType" + }, + "uniqueItems": true, + "minItems": 1 + } + }, + "required": ["allowedAuthTypes"] +} \ No newline at end of file diff --git a/application/single_app/static/json/schemas/plugin.schema.json b/application/single_app/static/json/schemas/plugin.schema.json index c9b80f8b..9add32ab 100644 --- a/application/single_app/static/json/schemas/plugin.schema.json +++ b/application/single_app/static/json/schemas/plugin.schema.json @@ -2,6 +2,20 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$ref": "#/definitions/Plugin", "definitions": { + "AuthType": { + "type": "string", + "enum": [ + "NoAuth", + "key", + "identity", + "user", + "servicePrincipal", + "connection_string", + "basic", + "username_password" + ], + "description": "Supported authentication types for plugins." + }, "Plugin": { "type": "object", "additionalProperties": false, @@ -40,9 +54,8 @@ "type": "object", "properties": { "type": { - "type": "string", - "enum": ["NoAuth", "key", "identity", "user", "servicePrincipal", "connection_string", "basic", "username_password"], - "description": "Auth type must be 'key', 'user', 'identity', 'servicePrincipal', 'connection_string', 'basic', or 'username_password'" + "$ref": "#/definitions/AuthType", + "description": "Auth type must be one of the supported authentication modes." }, "key": { "type": "string", diff --git a/application/single_app/templates/_agent_modal.html b/application/single_app/templates/_agent_modal.html index 536a43d1..a90260ec 100644 --- a/application/single_app/templates/_agent_modal.html +++ b/application/single_app/templates/_agent_modal.html @@ -15,6 +15,24 @@